chore: initial project commit

This commit is contained in:
Ignacio Ballesteros
2026-02-17 21:25:46 +01:00
commit 70f7fac23e
34 changed files with 6786 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Path to Zotero executable
# Linux: /usr/bin/zotero
# macOS: /Applications/Zotero.app/Contents/MacOS/zotero
# Windows: C:\Program Files\Zotero\zotero.exe
ZOTERO_PLUGIN_ZOTERO_BIN_PATH=
# Path to Zotero profile directory for development
# Find via: Help -> Troubleshooting Information -> Profile Directory
ZOTERO_PLUGIN_PROFILE_PATH=
# Data directory (optional, defaults to profile's default)
# ZOTERO_PLUGIN_DATA_DIR=

15
.eslintrc.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
project: "./tsconfig.json",
},
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
ignorePatterns: [".scaffold/", "node_modules/", "addon/bootstrap.js", "*.config.js"],
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
};

View File

@@ -0,0 +1,85 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Get version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Create release and upload assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}"
SERVER_URL="${GITHUB_SERVER_URL}"
API_URL="${SERVER_URL}/api/v1"
# Create the release
RELEASE=$(curl -s -X POST "${API_URL}/repos/${REPO}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"${TAG}\",
\"body\": \"Release ${TAG}\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "${RELEASE}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to create release. Response: ${RELEASE}"
exit 1
fi
echo "Created release ID: ${RELEASE_ID}"
# Upload all XPI files
for XPI in .scaffold/build/*.xpi; do
[ -f "${XPI}" ] || continue
FILENAME=$(basename "${XPI}")
echo "Uploading ${FILENAME}..."
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${XPI}"
done
# Upload update manifests if present
for JSON in .scaffold/build/update.json .scaffold/build/update-beta.json; do
[ -f "${JSON}" ] || continue
FILENAME=$(basename "${JSON}")
echo "Uploading ${FILENAME}..."
curl -s -X POST "${API_URL}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary "@${JSON}"
done
echo "Release ${TAG} published successfully."

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
# Build output
.scaffold/
*.xpi
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Test
coverage/
# Nix
result
.direnv/
# AI agent artifacts
.agent-shell/
opencode.json
# Internal development artifacts
PLAN.md
zotero-notes-export.ts

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

286
AGENTS.md Normal file
View File

@@ -0,0 +1,286 @@
# AGENTS.md
## Development Environment Commands
### Primary Commands (run inside `nix develop`)
```bash
# Development
nix develop # Enter dev shell with Node.js and tooling
npm run start # Start dev server with hot reload
npm run build # Build production XPI to .scaffold/build/
# Code Quality
npm run lint # Run ESLint on TypeScript source
npm run lint:fix # Auto-fix ESLint issues
npm run format # Format with Prettier
npm run format:check # Check formatting without changes
# Release
npm run release # Bump version, commit, tag, and build
```
### Testing
This project does not currently have automated tests. To test manually:
1. Run `npm run build` to create the XPI
2. Install the XPI in Zotero 7
3. Test export functionality manually via Tools menu
---
## Code Style Guidelines
### Imports and Exports
```typescript
// Use relative imports for internal modules
import { OrgExporter } from "./modules/exporter";
import type { Prefs } from "./modules/prefs";
// Use type-only imports where possible
import type { AddonInfo } from "./addon";
// Use default exports for single main entity
export class OrgExportAnnotationsAddon {}
export function createHooks() {}
// Use named exports for utilities
export async function convertToOrg() {}
export async function testPandoc() {}
```
**Rules:**
- Prefer named exports for functions
- Use `import type` for type-only imports
- Import from `./modules/` with relative paths
- No namespace imports (`import * as X`)
- No `export default` for modules (use named exports)
### Formatting (Prettier)
```json
{
"semi": true, // Always use semicolons
"singleQuote": false, // Use double quotes
"tabWidth": 2, // 2 space indentation
"trailingComma": "es5", // Trailing commas in valid ES5
"printWidth": 100 // Max line length
}
```
### TypeScript Configuration
- **Strict mode enabled**: All type checking rules active
- **Target**: ES2022 with DOM types
- **Module**: ESNext with bundler resolution
- **Source maps**: Enabled for debugging
- **Output**: Bundled to `.scaffold/build/addon/content/scripts/`
### Naming Conventions
| Entity | Convention | Examples |
| --------------- | ---------------- | ---------------------------------------------------------- |
| Classes | PascalCase | `OrgExporter`, `Prefs`, `AddonInfo` |
| Functions | camelCase | `convertToOrg`, `isAnnotationNote`, `ensureNotesDirectory` |
| Methods | camelCase | `init()`, `exportAttachment()`, `cleanupTempFile()` |
| Private Methods | camelCase | Prefix with `private` keyword |
| Constants | UPPER_SNAKE_CASE | `PREF_PREFIX`, `MENU_IDS` |
| Interfaces | PascalCase | `AddonInfo`, `Hooks` |
| Types | PascalCase | `export type Preference = ...` |
| File Names | kebab-case | `converter.ts`, `exporter.ts`, `preferences.js` |
```typescript
// Constants
const PREF_PREFIX = "extensions.zotero.orgexportannotations";
const MENU_IDS = { toolsExportAll: "...", itemExportSelected: "..." };
// Private methods
private async getLastAnnotationsNote(): Promise<Zotero.Item | undefined> {}
private isAnnotationNote(noteHTML: string): boolean {}
```
### Type Annotations
```typescript
// Explicit return types on functions
export async function convertToOrg(...): Promise<void> {}
export function testPandoc(pandocPath: string): Promise<boolean> {}
// Type assertions for Zotero API returns
const title = attachment.parentItem.getField("title") as string;
const citationKey = item.getField("citationKey") as string;
// Optional chaining with nullish coalescing
const citationKey = note.parentItem?.getField("citationKey") || "temp";
// Generic type parameters
private getPref<T>(key: string, defaultValue: T): T {
return (value as T) ?? defaultValue;
}
```
### Error Handling
```typescript
// Standard pattern with logging
try {
await operation();
Zotero.OrgExportAnnotations.log("Operation succeeded");
} catch (error) {
Zotero.OrgExportAnnotations.error("Operation failed", error as Error);
// Optionally re-throw
throw error;
}
// Empty catch blocks must use underscore prefix
try {
await cleanup();
} catch (_error) {
// Cleanup errors are non-critical
}
// Return error values instead of throwing for utility functions
export async function testPandoc(pandocPath: string): Promise<boolean> {
try {
await Zotero.Utilities.Internal.exec(pandocPath, ["--version"]);
return true;
} catch {
return false;
}
}
```
### Logging
```typescript
// Debug logging (respects debug mode)
Zotero.OrgExportAnnotations.log("Message", ...args);
// Error logging (always logged)
Zotero.OrgExportAnnotations.error("Message", error as Error);
// Warning logging (always logged)
Zotero.OrgExportAnnotations.warn("Message");
// Verbose debugging with objects
Zotero.OrgExportAnnotations.log("Processing item", { id, title });
```
### Async/Await Patterns
```typescript
// Use for...of for sequential async operations
for (const attachment of attachments) {
if (await this.exportAttachment(attachment)) {
count++;
}
}
// Early returns with await
if (!item.isFileAttachment()) return false;
if (!path) throw new Error("Notes path not configured");
```
### Zotero API Usage
```typescript
// Non-null assertions for known APIs
Zotero.BetterNotes!.api.convert.note2md(note, notesPath);
// Zotero utilities for file operations
await Zotero.File.putContentsAsync(path, content);
const content = await Zotero.File.getContentsAsync(path);
await Zotero.Utilities.Internal.exec(command, args);
// Zotero preferences
Zotero.Prefs.get(key, global);
Zotero.Prefs.set(key, value, global);
// Zotero items
const item = Zotero.Items.get(id);
await Zotero.Items.trashTx(id);
```
### Class Structure
```typescript
export class MyClass {
// Public properties
public property!: string;
// Private properties
private internal = { enabled: false };
// Constructor with dependency injection
constructor(private deps: Dependencies) {}
// Public methods
public async doSomething(): Promise<void> {
try {
// Implementation
} catch (error) {
this.handleError(error as Error);
}
}
// Private helper methods
private handleError(error: Error): void {
Zotero.OrgExportAnnotations.error("Error occurred", error);
}
}
```
### ESLint Rules
- **Unused variables**: Error, but ignored if prefixed with `_`
- **Explicit any**: Warning (prefer specific types)
- **No-empty**: Error for empty catch blocks (use `catch (_error)`)
### File Organization
```
src/
├── index.ts # Main entry point
├── addon.ts # Core addon class
├── hooks.ts # Lifecycle hooks
└── modules/
├── prefs.ts # Preferences management
├── exporter.ts # Core export logic
├── converter.ts # Format conversion
├── notifier.ts # Zotero event handlers
└── menu.ts # UI menu registration
```
### Common Patterns
**Preference getters/setters**:
```typescript
get notesPath(): string {
const path = this.getPref("notesPath", "");
if (path && path.startsWith("~")) {
return path.replace("~", PathUtils.profileDir);
}
return path;
}
set notesPath(value: string) {
this.setPref("notesPath", value);
}
```
**Filter operations**:
```typescript
return items
.filter((item) => this.checkCondition(item))
.filter((item) => item.parentItem !== undefined);
```
**Reduce operations**:
```typescript
return notes.reduce((max, current) => (current.dateModified > max.dateModified ? current : max));
```

211
README.md Normal file
View File

@@ -0,0 +1,211 @@
# Zotero Org Export Annotations
Export PDF and EPUB annotations from Zotero to Org-mode files for use with Emacs, org-roam, and citar.
## Features
- Export annotations to individual Org files named by citation key
- Automatic export after Zotero sync completes
- Export when closing a PDF/EPUB reader tab
- Configurable output directory
- Optional attachment of Org files to Zotero items
## Requirements
- **[Zotero](https://www.zotero.org/) 7.0+**
- **[Better Notes](https://github.com/windingwind/zotero-better-notes)** plugin — must be installed before this plugin
- **[Pandoc](https://pandoc.org/)** — for Markdown to Org conversion
## Installation
1. Install the **Better Notes** plugin first (see its own instructions)
2. Install **Pandoc** and ensure it is on your `PATH` (or note its full path)
3. Download the latest `.xpi` file from [Releases](../../releases)
4. In Zotero, go to **Tools → Add-ons**
5. Click the gear icon (top-right) and select **Install Add-on From File...**
6. Select the downloaded `.xpi` file and restart Zotero if prompted
## Configuration
After installation, open **Tools → Org Export Preferences** to configure:
| Preference | Default | Description |
| ----------------------- | --------- | --------------------------------------------------------------- |
| Notes Directory | _(empty)_ | Directory where Org files will be saved. Supports `~` for home. |
| Pandoc Path | `pandoc` | Full path to the pandoc binary if not on `PATH` |
| Attach Org file to item | `false` | Link exported Org files as Zotero attachments |
| Auto-export on sync | `true` | Export automatically after Zotero sync completes |
| Export on tab close | `true` | Export when closing a PDF/EPUB reader tab |
| Show notifications | `true` | Display a progress notification during export |
| Debug mode | `false` | Enable verbose logging to the Zotero error console |
## Usage
### Manual Export
- **Export all items**: **Tools → Export All Annotations to Org**
- **Export selected items**: Right-click one or more items → **Export Annotations to Org**
### Automatic Export
When enabled in preferences, exports happen automatically:
- After a Zotero sync completes (only items with new/changed annotations are exported)
- When you close a PDF or EPUB reader tab
### Output Format
Files are written to the configured Notes Directory, named by citation key:
```
~/org/notes/smith2020.org
~/org/notes/doe2019.org
```
Each file contains:
```org
#+title: The Full Title of the Referenced Work
* Annotation heading
Converted annotation text...
```
Only items that have a citation key (via Better BibTeX or the `citationKey` field) will be exported.
## For Maintainers
This section covers building, developing, and releasing the plugin.
### Architecture
```
src/
├── index.ts # Entry point: mounts addon to Zotero global
├── addon.ts # Core addon class (logging, init, module wiring)
├── hooks.ts # Lifecycle hooks (startup/shutdown, menu actions)
└── modules/
├── prefs.ts # Typed read/write for all preferences
├── exporter.ts # Attachment detection, sync checking, batch export
├── converter.ts # Markdown → Org via pandoc, file I/O
├── notifier.ts # Zotero event handlers (sync finish, tab close)
└── menu.ts # Menu registration (Zotero 7 + 8 compatible)
addon/ # Static plugin files copied verbatim into the XPI
├── manifest.json
├── bootstrap.js # Zotero plugin lifecycle entry point
├── prefs.js # Default preference values
└── content/
├── preferences.xhtml # Preferences pane UI (XUL)
├── scripts/preferences.js
├── icons/
└── locale/en-US/ # Fluent (.ftl) string bundles
```
The build system uses [zotero-plugin-scaffold](https://github.com/windingwind/zotero-plugin-scaffold) with esbuild, which bundles `src/` into a single `index.js` and packages everything as an XPI.
### Prerequisites
**With Nix (recommended):**
```bash
# Requires Nix with flakes enabled
nix develop
```
This provides Node.js 20, npm, TypeScript, ESLint, Prettier, and Pandoc.
**Without Nix:**
- Node.js 20+
- npm
- Pandoc (for manual testing)
```bash
npm install
```
### Environment Setup
Copy the environment template and fill in your local paths:
```bash
cp .env.example .env
```
Edit `.env`:
```bash
# Path to the Zotero binary (required for the dev server)
ZOTERO_PLUGIN_ZOTERO_BIN_PATH=/path/to/zotero
# Path to a Zotero development profile directory
ZOTERO_PLUGIN_PROFILE_PATH=/path/to/zotero-dev-profile
```
Create a dedicated Zotero profile for development to avoid affecting your main library.
### Development Workflow
```bash
# Start the dev server — builds and hot-reloads the plugin into a running Zotero instance
npm start
# One-off build — outputs XPI to .scaffold/build/
npm run build
# Lint TypeScript source
npm run lint
npm run lint:fix # auto-fix
# Format source files
npm run format
npm run format:check # check without writing
```
The dev server (`npm start`) watches for changes in `src/` and `addon/`, rebuilds, and reloads the plugin inside Zotero automatically. Zotero must already be running with the configured profile.
### Testing
There are no automated tests. To test manually:
1. `npm run build` to produce the XPI
2. Install the XPI in a Zotero 7 instance via **Tools → Add-ons → Install Add-on From File...**
3. Exercise the export functionality manually via the Tools menu and right-click context menu
### Releasing
Releases are created by pushing a version tag. The CI/CD pipeline (Gitea Actions) will build the XPI and publish a release automatically.
To cut a release locally:
```bash
npm run release
```
This runs [bumpp](https://github.com/antfu/bumpp) interactively to select the new version, then:
1. Updates `package.json` version
2. Commits and tags the version
3. Pushes the commit and tag to the remote
4. Builds the XPI
Once the tag is pushed, Gitea Actions takes over and creates the release with the XPI attached.
### Code Style
- **Formatter**: Prettier (config in `.prettierrc`) — double quotes, 2-space indent, semicolons
- **Linter**: ESLint with TypeScript-ESLint (config in `.eslintrc.js`) — strict mode
- **Naming**: PascalCase for classes/types, camelCase for functions/methods, UPPER_SNAKE_CASE for constants
- **Imports**: relative paths, `import type` for type-only imports, no namespace imports
- **Error handling**: always catch with a typed `error as Error`; use `_error` for non-critical ignores
See `AGENTS.md` for the full style guide used during AI-assisted development.
## License
GPL-3.0-or-later
## Credits
Based on the [zotero-plugin-template](https://github.com/windingwind/zotero-plugin-template) by windingwind.

70
addon/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,70 @@
/* eslint-disable no-undef */
var chromeHandle;
async function startup({ id, version, rootURI }, reason) {
await Zotero.initializationPromise;
try {
const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(
Ci.amIAddonManagerStartup
);
const manifestURI = Services.io.newURI(rootURI + "manifest.json");
chromeHandle = aomStartup.registerChrome(manifestURI, [
["content", "orgexportannotations", rootURI + "content/"],
]);
Services.scriptloader.loadSubScript(rootURI + "content/scripts/index.js");
if (typeof Zotero.OrgExportAnnotations === "undefined") {
throw new Error("Zotero.OrgExportAnnotations not loaded from index.js");
}
await Zotero.OrgExportAnnotations.init({ id, version, rootURI });
await Zotero.OrgExportAnnotations.hooks.onStartup();
} catch (error) {
Zotero.logError("[OrgExportAnnotations] Startup failed: " + error);
throw error;
}
}
async function onMainWindowLoad({ window }) {
try {
await Zotero.OrgExportAnnotations?.hooks?.onMainWindowLoad(window);
} catch (error) {
Zotero.logError("[OrgExportAnnotations] onMainWindowLoad failed: " + error);
}
}
async function onMainWindowUnload({ window }) {
try {
await Zotero.OrgExportAnnotations?.hooks?.onMainWindowUnload(window);
} catch (error) {
Zotero.logError("[OrgExportAnnotations] onMainWindowUnload failed: " + error);
}
}
function shutdown({ id, version, rootURI }, reason) {
if (reason === APP_SHUTDOWN) {
return;
}
try {
Zotero.OrgExportAnnotations?.hooks?.onShutdown();
} catch (error) {
Zotero.logError("[OrgExportAnnotations] onShutdown failed: " + error);
}
try {
chromeHandle?.destruct();
} catch (error) {
Zotero.logError("[OrgExportAnnotations] Failed to destruct chrome handle: " + error);
}
chromeHandle = null;
delete Zotero.OrgExportAnnotations;
}
function install() {}
function uninstall() {}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="-7.65 -13.389 144.98 160"><path fill="#a04d32" stroke="#000" stroke-width="3" d="M133.399 46.067c-.205-3.15-2.842-4.366-5.993-2.125-7.22-1.297-14.305-.687-17.8-.981-7.662-1.073-14.041-5.128-14.041-5.128.932-1.239.486-3.917-5.498-4.101-1.646-.542-3.336-1.327-4.933-1.979.544-1.145-.133-2.836-.133-2.836 2.435-.672 2.808-3.842 1.848-5.709 3.106.084 2.612-4.718 2.183-6.381 2.435-.923 2.77-3.831 1.763-6.129 2.938-.671 3.022-4.114 2.77-6.548 3.023-.168 2.604-5.457 2.604-6.549 2.604-1.679 2.016-3.946 2.425-6.573 1.605-3.25-.577-4.173-2.116-.71-1.651 3.001-3.77 4.311-3.75 6.528.755 1.259-5.625 3.106-3.61 7.052-1.428 1.763-4.785 4.03-3.592 6.733-.606 1.326-4.888 4.433-3.041 7.371-4.03 2.687-3.79 3.335-2.938 5.793-1.147.736-2.318 1.862-2.995 3.094-1.32-1.568-2.603-4.429-2.584-8.294 0-3.275-6.1.318-6.1 6.784 0 .556-.056 1.061-.134 1.542-2.11.243-4.751.707-8.08 1.494-.106.073-.157.186-.182.316a8.704 8.704 0 01-.277-1.553c-.582-3.79-4.934-9.56-7.057-2.434-1.096 2.611-1.74 4.392-2.115 5.789v0s-.336.226-.957.61c-2.62 1.622-3.562 6.686-13.075 9.883-3.211 1.079-7.4 1.945-12.96 2.395-9.57.773-27.887 17.314-29.114 33.097-.283 3.964.31 13.737 3.596 22.31l.005.02c.015.042.032.081.048.122.052.134.103.267.156.398.28.718.579 1.405.895 2.062 1.885 4.028 4.46 7.59 7.934 9.882a25.252 25.252 0 004.372 2.762c5.907 9.749 18.442 22.252 42.075 14.859 36.255-10.284 56.263 13.809 58.568 15.5 3.399 3.433-8.786-29.835-34.587-44.788-15.253-8.322-5.678-22.656-4.585-27.718 0 0 12.227 8.557 21.087-4.52 8.004 2.062 13.367-1.462 20.25 1.03 4.184 1.833 21.77.726 15.235-9.104 4.11-2.683 4.544-1.815 6.6-5.9 1.104-4.952-1.403-6.012-2.167-7.366z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="-7.65 -13.389 144.98 160"><path fill="#a04d32" stroke="#000" stroke-width="3" d="M133.399 46.067c-.205-3.15-2.842-4.366-5.993-2.125-7.22-1.297-14.305-.687-17.8-.981-7.662-1.073-14.041-5.128-14.041-5.128.932-1.239.486-3.917-5.498-4.101-1.646-.542-3.336-1.327-4.933-1.979.544-1.145-.133-2.836-.133-2.836 2.435-.672 2.808-3.842 1.848-5.709 3.106.084 2.612-4.718 2.183-6.381 2.435-.923 2.77-3.831 1.763-6.129 2.938-.671 3.022-4.114 2.77-6.548 3.023-.168 2.604-5.457 2.604-6.549 2.604-1.679 2.016-3.946 2.425-6.573 1.605-3.25-.577-4.173-2.116-.71-1.651 3.001-3.77 4.311-3.75 6.528.755 1.259-5.625 3.106-3.61 7.052-1.428 1.763-4.785 4.03-3.592 6.733-.606 1.326-4.888 4.433-3.041 7.371-4.03 2.687-3.79 3.335-2.938 5.793-1.147.736-2.318 1.862-2.995 3.094-1.32-1.568-2.603-4.429-2.584-8.294 0-3.275-6.1.318-6.1 6.784 0 .556-.056 1.061-.134 1.542-2.11.243-4.751.707-8.08 1.494-.106.073-.157.186-.182.316a8.704 8.704 0 01-.277-1.553c-.582-3.79-4.934-9.56-7.057-2.434-1.096 2.611-1.74 4.392-2.115 5.789v0s-.336.226-.957.61c-2.62 1.622-3.562 6.686-13.075 9.883-3.211 1.079-7.4 1.945-12.96 2.395-9.57.773-27.887 17.314-29.114 33.097-.283 3.964.31 13.737 3.596 22.31l.005.02c.015.042.032.081.048.122.052.134.103.267.156.398.28.718.579 1.405.895 2.062 1.885 4.028 4.46 7.59 7.934 9.882a25.252 25.252 0 004.372 2.762c5.907 9.749 18.442 22.252 42.075 14.859 36.255-10.284 56.263 13.809 58.568 15.5 3.399 3.433-8.786-29.835-34.587-44.788-15.253-8.322-5.678-22.656-4.585-27.718 0 0 12.227 8.557 21.087-4.52 8.004 2.062 13.367-1.462 20.25 1.03 4.184 1.833 21.77.726 15.235-9.104 4.11-2.683 4.544-1.815 6.6-5.9 1.104-4.952-1.403-6.012-2.167-7.366z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-7.65 -13.389 144.98 160"><path fill="#a04d32" stroke="#000" stroke-width="3" d="M133.399 46.067c-.205-3.15-2.842-4.366-5.993-2.125-7.22-1.297-14.305-.687-17.8-.981-7.662-1.073-14.041-5.128-14.041-5.128.932-1.239.486-3.917-5.498-4.101-1.646-.542-3.336-1.327-4.933-1.979.544-1.145-.133-2.836-.133-2.836 2.435-.672 2.808-3.842 1.848-5.709 3.106.084 2.612-4.718 2.183-6.381 2.435-.923 2.77-3.831 1.763-6.129 2.938-.671 3.022-4.114 2.77-6.548 3.023-.168 2.604-5.457 2.604-6.549 2.604-1.679 2.016-3.946 2.425-6.573 1.605-3.25-.577-4.173-2.116-.71-1.651 3.001-3.77 4.311-3.75 6.528.755 1.259-5.625 3.106-3.61 7.052-1.428 1.763-4.785 4.03-3.592 6.733-.606 1.326-4.888 4.433-3.041 7.371-4.03 2.687-3.79 3.335-2.938 5.793-1.147.736-2.318 1.862-2.995 3.094-1.32-1.568-2.603-4.429-2.584-8.294 0-3.275-6.1.318-6.1 6.784 0 .556-.056 1.061-.134 1.542-2.11.243-4.751.707-8.08 1.494-.106.073-.157.186-.182.316a8.704 8.704 0 01-.277-1.553c-.582-3.79-4.934-9.56-7.057-2.434-1.096 2.611-1.74 4.392-2.115 5.789v0s-.336.226-.957.61c-2.62 1.622-3.562 6.686-13.075 9.883-3.211 1.079-7.4 1.945-12.96 2.395-9.57.773-27.887 17.314-29.114 33.097-.283 3.964.31 13.737 3.596 22.31l.005.02c.015.042.032.081.048.122.052.134.103.267.156.398.28.718.579 1.405.895 2.062 1.885 4.028 4.46 7.59 7.934 9.882a25.252 25.252 0 004.372 2.762c5.907 9.749 18.442 22.252 42.075 14.859 36.255-10.284 56.263 13.809 58.568 15.5 3.399 3.433-8.786-29.835-34.587-44.788-15.253-8.322-5.678-22.656-4.585-27.718 0 0 12.227 8.557 21.087-4.52 8.004 2.062 13.367-1.462 20.25 1.03 4.184 1.833 21.77.726 15.235-9.104 4.11-2.683 4.544-1.815 6.6-5.9 1.104-4.952-1.403-6.012-2.167-7.366z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,83 @@
<vbox id="orgexportannotations-prefs"
onload="window.OrgExportAnnotationsPrefs?.init()">
<linkset>
<html:link rel="localization" href="orgexportannotations-addon.ftl"/>
<html:link rel="localization" href="orgexportannotations-preferences.ftl"/>
</linkset>
<!-- Paths Section -->
<groupbox>
<caption data-l10n-id="prefs-section-paths"/>
<vbox class="orgexportannotations-pref-row">
<hbox align="center">
<label data-l10n-id="prefs-notes-path"
control="orgexportannotations-notes-path"/>
<html:input id="orgexportannotations-notes-path"
type="text"
preference="extensions.zotero.orgexportannotations.notesPath"
style="flex: 1;"/>
<button data-l10n-id="prefs-browse"
oncommand="window.OrgExportAnnotationsPrefs?.browseNotesPath()"/>
</hbox>
<description data-l10n-id="prefs-notes-path-description"
class="orgexportannotations-pref-description"/>
</vbox>
<vbox class="orgexportannotations-pref-row">
<hbox align="center">
<label data-l10n-id="prefs-pandoc-path"
control="orgexportannotations-pandoc-path"/>
<html:input id="orgexportannotations-pandoc-path"
type="text"
preference="extensions.zotero.orgexportannotations.pandocPath"
style="flex: 1;"/>
<button data-l10n-id="prefs-browse"
oncommand="window.OrgExportAnnotationsPrefs?.browsePandocPath()"/>
<button data-l10n-id="prefs-test-pandoc"
oncommand="window.OrgExportAnnotationsPrefs?.testPandoc()"/>
</hbox>
<description data-l10n-id="prefs-pandoc-path-description"
class="orgexportannotations-pref-description"/>
</vbox>
</groupbox>
<!-- Behavior Section -->
<groupbox>
<caption data-l10n-id="prefs-section-behavior"/>
<checkbox id="orgexportannotations-attach-org"
data-l10n-id="prefs-attach-org"
preference="extensions.zotero.orgexportannotations.attachOrgFile"/>
<checkbox id="orgexportannotations-auto-export-sync"
data-l10n-id="prefs-auto-export-sync"
preference="extensions.zotero.orgexportannotations.autoExportOnSync"/>
<checkbox id="orgexportannotations-export-tab-close"
data-l10n-id="prefs-export-tab-close"
preference="extensions.zotero.orgexportannotations.exportOnTabClose"/>
<checkbox id="orgexportannotations-show-notification"
data-l10n-id="prefs-show-notification"
preference="extensions.zotero.orgexportannotations.showNotification"/>
</groupbox>
<!-- Advanced Section -->
<groupbox>
<caption data-l10n-id="prefs-section-advanced"/>
<checkbox id="orgexportannotations-debug"
data-l10n-id="prefs-debug"
preference="extensions.zotero.orgexportannotations.debug"/>
<hbox>
<button data-l10n-id="prefs-export-now"
oncommand="window.OrgExportAnnotationsPrefs?.exportAllNow()"/>
<button data-l10n-id="prefs-force-export"
oncommand="window.OrgExportAnnotationsPrefs?.forceExportAllNow()"/>
</hbox>
</groupbox>
</vbox>

View File

@@ -0,0 +1,76 @@
/* eslint-disable no-undef */
const OrgExportAnnotationsPrefs = {
init() {
try {
console.log("[OrgExportAnnotationsPrefs] Initializing preferences pane");
} catch (error) {
console.error("[OrgExportAnnotationsPrefs] Init error:", error);
}
},
async browseNotesPath() {
const fp = new FilePicker();
// Use browsingContext for Zotero 8+, fall back to window for Zotero 7
const context = window.browsingContext || window;
fp.init(context, "Select Notes Directory", fp.modeGetFolder);
const result = await new Promise((resolve) => fp.open(resolve));
if (result === fp.returnOK) {
const input = document.getElementById("orgexportannotations-notes-path");
if (input) {
input.value = fp.file.path;
input.dispatchEvent(new Event("change"));
}
}
},
async browsePandocPath() {
const fp = new FilePicker();
// Use browsingContext for Zotero 8+, fall back to window for Zotero 7
const context = window.browsingContext || window;
fp.init(context, "Select Pandoc Executable", fp.modeOpen);
const result = await new Promise((resolve) => fp.open(resolve));
if (result === fp.returnOK) {
const input = document.getElementById("orgexportannotations-pandoc-path");
if (input) {
input.value = fp.file.path;
input.dispatchEvent(new Event("change"));
}
}
},
async testPandoc() {
const input = document.getElementById("orgexportannotations-pandoc-path");
const pandocPath = (input && input.value) || "pandoc";
try {
await Zotero.Utilities.Internal.exec(pandocPath, ["--version"]);
alert("Pandoc is working correctly!");
} catch (_error) {
alert(`Pandoc not found at: ${pandocPath}\n\nPlease check the path.`);
}
},
async exportAllNow() {
try {
if (Zotero.OrgExportAnnotations && Zotero.OrgExportAnnotations.hooks) {
await Zotero.OrgExportAnnotations.hooks.onMenuExportAll();
}
} catch (error) {
alert(`Export failed: ${error.message}`);
}
},
async forceExportAllNow() {
try {
if (Zotero.OrgExportAnnotations && Zotero.OrgExportAnnotations.hooks) {
await Zotero.OrgExportAnnotations.hooks.onMenuForceExportAll();
}
} catch (error) {
alert(`Force export failed: ${error.message}`);
}
},
};
window.OrgExportAnnotationsPrefs = OrgExportAnnotationsPrefs;

View File

@@ -0,0 +1,27 @@
name = Zotero Org Export Annotations
description = Export PDF/EPUB annotations to Org-mode files
menu-export-all = Export All Annotations to Org
menu-export-selected = Export Selected to Org
menu-preferences = Org Export Preferences...
notification-export-complete =
{ $count ->
[one] Exported { $count } item to Org
*[other] Exported { $count } items to Org
}
notification-export-error = Export failed: { $error }
notification-no-items = No items with annotations to export
notification-config-required = Please configure the notes path in preferences
error-better-notes-required =
Better Notes plugin is required for Org export.
Please install it from: https://github.com/windingwind/zotero-better-notes
error-pandoc-not-found =
Pandoc not found at: { $path }
Please install pandoc or configure the correct path in preferences.
error-notes-path-not-set =
Notes output path is not configured.
Please set it in Tools > Org Export Preferences.
error-notes-path-invalid =
Notes path does not exist: { $path }

View File

@@ -0,0 +1,40 @@
prefs-title = Org Export Annotations
prefs-section-paths =
.label = Output Paths
prefs-notes-path =
.value = Notes Directory:
prefs-notes-path-description =
Directory where Org files will be saved.
Files are named using the citation key (e.g., smith2020.org).
prefs-pandoc-path =
.value = Pandoc Path:
prefs-pandoc-path-description =
Path to pandoc executable. Leave as "pandoc" to use system PATH.
prefs-browse =
.label = Browse...
prefs-section-behavior =
.label = Behavior
prefs-attach-org =
.label = Attach Org files to Zotero items
prefs-attach-org-description =
Link generated Org files as attachments to their parent items.
prefs-auto-export-sync =
.label = Export automatically after Zotero sync
prefs-export-tab-close =
.label = Export when closing reader tab
prefs-show-notification =
.label = Show notification after export
prefs-section-advanced =
.label = Advanced
prefs-debug =
.label = Enable debug logging
prefs-export-now =
.label = Export All Now
prefs-force-export =
.label = Force Export All
prefs-test-pandoc =
.label = Test Pandoc

20
addon/manifest.json Normal file
View File

@@ -0,0 +1,20 @@
{
"manifest_version": 2,
"name": "Zotero Org Export Annotations",
"version": "1.0.0",
"description": "Export Zotero PDF/EPUB annotations to Org-mode files for Emacs, org-roam, and citar",
"author": "Ignacio",
"homepage_url": "https://github.com/ignaciobll/zotero-org-export-annotations",
"icons": {
"48": "content/icons/icon.svg",
"96": "content/icons/icon@2x.svg"
},
"applications": {
"zotero": {
"id": "org-export-annotations@zotero.org",
"update_url": "https://github.com/ignaciobll/zotero-org-export-annotations/releases/download/release/update.json",
"strict_min_version": "7.0",
"strict_max_version": "8.*"
}
}
}

7
addon/prefs.js Normal file
View File

@@ -0,0 +1,7 @@
pref("extensions.zotero.orgexportannotations.notesPath", "");
pref("extensions.zotero.orgexportannotations.pandocPath", "pandoc");
pref("extensions.zotero.orgexportannotations.attachOrgFile", false);
pref("extensions.zotero.orgexportannotations.autoExportOnSync", true);
pref("extensions.zotero.orgexportannotations.exportOnTabClose", true);
pref("extensions.zotero.orgexportannotations.showNotification", true);
pref("extensions.zotero.orgexportannotations.debug", false);

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771008912,
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

72
flake.nix Normal file
View File

@@ -0,0 +1,72 @@
{
description = "Zotero Org Export Annotations - Export Zotero annotations to Org-mode files";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Node.js ecosystem
nodejs_20
nodePackages.npm
# TypeScript tooling
nodePackages.typescript
nodePackages.typescript-language-server
# Code quality
nodePackages.eslint
nodePackages.prettier
# Runtime dependency (for testing exports)
pandoc
];
shellHook = ''
echo ""
echo ""
echo " Zotero Org Export Annotations - Dev Environment "
echo ""
echo " Commands: "
echo " npm install - Install dependencies "
echo " npm start - Start dev server with hot reload "
echo " npm run build - Build production XPI "
echo " npm run lint - Run ESLint "
echo ""
echo ""
export PATH="$PWD/node_modules/.bin:$PATH"
'';
};
packages.default = pkgs.stdenv.mkDerivation {
pname = "zotero-org-export-annotations";
version = "1.0.0";
src = ./.;
nativeBuildInputs = with pkgs; [
nodejs_20
nodePackages.npm
];
buildPhase = ''
export HOME=$(mktemp -d)
npm ci --ignore-scripts
npm run build
'';
installPhase = ''
mkdir -p $out
cp -r .scaffold/build/*.xpi $out/
'';
};
}
);
}

4488
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "zotero-org-export-annotations",
"version": "1.0.0",
"description": "Export Zotero PDF/EPUB annotations to Org-mode files for Emacs integration",
"config": {
"addonName": "Zotero Org Export Annotations",
"addonID": "org-export-annotations@zotero.org",
"addonRef": "orgexportannotations",
"addonInstance": "OrgExportAnnotations",
"prefsPrefix": "extensions.zotero.orgexportannotations"
},
"main": "src/index.ts",
"scripts": {
"start": "zotero-plugin serve",
"build": "zotero-plugin build",
"release": "bumpp --commit --push --tag && npm run build",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.ts\" \"addon/**/*.js\"",
"format:check": "prettier --check \"src/**/*.ts\" \"addon/**/*.js\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/ignaciobll/zotero-org-export-annotations.git"
},
"keywords": [
"zotero",
"zotero-plugin",
"org-mode",
"emacs",
"annotations",
"pdf",
"org-roam",
"citar"
],
"author": "Ignacio",
"license": "GPL-3.0-or-later",
"bugs": {
"url": "https://github.com/ignaciobll/zotero-org-export-annotations/issues"
},
"homepage": "https://github.com/ignaciobll/zotero-org-export-annotations#readme",
"devDependencies": {
"@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"bumpp": "^9.3.0",
"eslint": "^8.56.0",
"prettier": "^3.2.0",
"typescript": "^5.3.0",
"zotero-plugin-scaffold": "^0.8.3",
"zotero-types": "^4.0.5"
}
}

53
src/addon.ts Normal file
View File

@@ -0,0 +1,53 @@
import { OrgExporter } from "./modules/exporter";
import { Prefs } from "./modules/prefs";
export interface AddonInfo {
id: string;
version: string;
rootURI: string;
}
export class OrgExportAnnotationsAddon {
public id!: string;
public version!: string;
public rootURI!: string;
public data = {
env: typeof __env__ !== "undefined" ? __env__ : "production",
initialized: false,
};
public prefs!: Prefs;
public exporter!: OrgExporter;
init(info: AddonInfo): void {
this.id = info.id;
this.version = info.version;
this.rootURI = info.rootURI;
this.prefs = new Prefs();
this.exporter = new OrgExporter(this.prefs);
this.log(`Initialized version ${this.version}`);
}
log(message: string, ...args: unknown[]): void {
if (this.data.env === "development" || this.prefs?.debug) {
Zotero.debug(`[OrgExportAnnotations] ${message}`, false);
if (args.length > 0) {
Zotero.debug(JSON.stringify(args, null, 2), false);
}
}
}
error(message: string, error?: Error): void {
Zotero.logError(`[OrgExportAnnotations] ${message}`);
if (error) {
Zotero.logError(error);
}
}
warn(message: string): void {
Zotero.debug(`[OrgExportAnnotations] [WARN] ${message}`, false);
}
}

157
src/hooks.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { OrgExportAnnotationsAddon } from "./addon";
import { registerNotifiers, unregisterNotifiers } from "./modules/notifier";
import { registerMenus, unregisterMenus } from "./modules/menu";
export interface Hooks {
onStartup(): Promise<void>;
onShutdown(): void;
onMainWindowLoad(window: Window): Promise<void>;
onMainWindowUnload(window: Window): Promise<void>;
onMenuExportAll(): Promise<void>;
onMenuForceExportAll(): Promise<void>;
onMenuExportSelected(): Promise<void>;
}
function showNotification(message: string): void {
const pw = new Zotero.ProgressWindow({ closeOnClick: true });
pw.changeHeadline("Org Export");
pw.addDescription(message);
pw.show();
pw.startCloseTimer(4000);
}
export function createHooks(addon: OrgExportAnnotationsAddon): Hooks {
let notifierIDs: string[] = [];
return {
async onStartup(): Promise<void> {
addon.log("Starting up...");
try {
// Wait for Zotero UI to be fully ready before registering menus
await Promise.all([
Zotero.initializationPromise,
Zotero.unlockPromise,
Zotero.uiReadyPromise,
]);
if (!Zotero.BetterNotes) {
addon.warn("Better Notes plugin not found - some features may not work");
}
Zotero.PreferencePanes.register({
pluginID: addon.id,
src: addon.rootURI + "content/preferences.xhtml",
scripts: [addon.rootURI + "content/scripts/preferences.js"],
label: "Org Export",
image: addon.rootURI + "content/icons/icon.svg",
});
notifierIDs = registerNotifiers(addon);
// Register menus for any windows that are already open
// (onMainWindowLoad may have fired before our startup completed)
const mainWindows = Zotero.getMainWindows();
for (const win of mainWindows) {
await this.onMainWindowLoad(win);
}
addon.data.initialized = true;
addon.log("Startup complete");
} catch (error) {
addon.error("Startup failed", error as Error);
addon.data.initialized = false;
throw error;
}
},
onShutdown(): void {
addon.log("Shutting down...");
try {
unregisterNotifiers(notifierIDs);
notifierIDs = [];
addon.data.initialized = false;
addon.log("Shutdown complete");
} catch (error) {
addon.error("Shutdown failed", error as Error);
}
},
async onMainWindowLoad(window: Window): Promise<void> {
addon.log("Main window loaded");
registerMenus(addon, window);
},
async onMainWindowUnload(window: Window): Promise<void> {
addon.log("Main window unloading");
unregisterMenus(window);
},
async onMenuExportAll(): Promise<void> {
addon.log("Menu: Export All triggered");
try {
const count = await addon.exporter.syncAll();
if (addon.prefs.showNotification) {
const message =
count > 0
? `Exported ${count} item${count === 1 ? "" : "s"} to Org`
: "No items needed export";
showNotification(message);
}
} catch (error) {
addon.error("Export All failed", error as Error);
showNotification(`Export failed: ${(error as Error).message}`);
}
},
async onMenuForceExportAll(): Promise<void> {
addon.log("Menu: Force Export All triggered");
try {
const count = await addon.exporter.forceExportAll();
if (addon.prefs.showNotification) {
const message =
count > 0
? `Force exported ${count} item${count === 1 ? "" : "s"} to Org`
: "No items with annotations to export";
showNotification(message);
}
} catch (error) {
addon.error("Force Export All failed", error as Error);
showNotification(`Export failed: ${(error as Error).message}`);
}
},
async onMenuExportSelected(): Promise<void> {
addon.log("Menu: Export Selected triggered");
try {
const zoteroPane = Zotero.getActiveZoteroPane();
const selectedItems = zoteroPane.getSelectedItems();
if (selectedItems.length === 0) {
showNotification("No items selected");
return;
}
let count = 0;
for (const item of selectedItems) {
const exported = await addon.exporter.exportItem(item);
if (exported) count++;
}
if (addon.prefs.showNotification) {
const message =
count > 0
? `Exported ${count} item${count === 1 ? "" : "s"} to Org`
: "No annotations found in selected items";
showNotification(message);
}
} catch (error) {
addon.error("Export Selected failed", error as Error);
showNotification(`Export failed: ${(error as Error).message}`);
}
},
};
}

31
src/index.ts Normal file
View File

@@ -0,0 +1,31 @@
import { OrgExportAnnotationsAddon } from "./addon";
import { createHooks } from "./hooks";
const addon = new OrgExportAnnotationsAddon();
const hooks = createHooks(addon);
Zotero.OrgExportAnnotations = {
init: addon.init.bind(addon),
log: addon.log.bind(addon),
error: addon.error.bind(addon),
warn: addon.warn.bind(addon),
get id() {
return addon.id;
},
get version() {
return addon.version;
},
get rootURI() {
return addon.rootURI;
},
get data() {
return addon.data;
},
get prefs() {
return addon.prefs;
},
get exporter() {
return addon.exporter;
},
hooks,
};

58
src/modules/converter.ts Normal file
View File

@@ -0,0 +1,58 @@
export async function convertToOrg(
note: Zotero.Item,
notesPath: string,
orgPath: string,
title: string,
pandocPath: string
): Promise<void> {
const citationKey = note.parentItem?.getField("citationKey") || "temp";
const mdPath = PathUtils.join(notesPath, `${citationKey}.md`);
try {
const mdContent = await Zotero.BetterNotes!.api.convert.note2md(note, notesPath);
await Zotero.File.putContentsAsync(mdPath, mdContent);
await Zotero.Utilities.Internal.exec(pandocPath, [
"--from=markdown",
"--to=org",
`--output=${orgPath}`,
mdPath,
]);
await prependOrgTitle(orgPath, title);
await cleanupTempFile(mdPath);
} catch (error) {
try {
await cleanupTempFile(mdPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
async function prependOrgTitle(orgPath: string, title: string): Promise<void> {
const orgContent = await Zotero.File.getContentsAsync(orgPath);
const withTitle = `#+title: ${title}\n${orgContent}`;
await Zotero.File.putContentsAsync(orgPath, withTitle);
}
async function cleanupTempFile(path: string): Promise<void> {
try {
if (await IOUtils.exists(path)) {
await IOUtils.remove(path);
}
} catch (_error) {
Zotero.OrgExportAnnotations.warn(`Failed to clean up temp file: ${path}`);
}
}
export async function testPandoc(pandocPath: string): Promise<boolean> {
try {
await Zotero.Utilities.Internal.exec(pandocPath, ["--version"]);
return true;
} catch (_error) {
return false;
}
}

280
src/modules/exporter.ts Normal file
View File

@@ -0,0 +1,280 @@
import type { Prefs } from "./prefs";
import { convertToOrg } from "./converter";
export class OrgExporter {
constructor(private prefs: Prefs) {}
isAttachmentWithExtractableAnnotations(item: Zotero.Item): boolean {
if (!item.isFileAttachment()) {
Zotero.OrgExportAnnotations.warn(`Item ${item.id}: not a file attachment`);
return false;
}
const isSupported =
item.isPDFAttachment() || item.isEPUBAttachment() || item.isSnapshotAttachment();
if (!isSupported) {
Zotero.OrgExportAnnotations.warn(
`Item ${item.id}: unsupported attachment type (not PDF/EPUB/Snapshot)`
);
return false;
}
try {
const annotations = item.getAnnotations();
Zotero.OrgExportAnnotations.warn(
`Item ${item.id}: found ${annotations?.length ?? 0} annotations`
);
return annotations && annotations.length > 0;
} catch (error) {
Zotero.OrgExportAnnotations.error("Failed to check annotations", error as Error);
return false;
}
}
private isAnnotationNote(noteHTML: string): boolean {
return (
noteHTML.startsWith("<div data-citation-items") ||
noteHTML.startsWith("<div datap-citation-items") ||
noteHTML.startsWith("<h1>Annotations")
);
}
private async getLastAnnotationsNote(item: Zotero.Item): Promise<Zotero.Item | undefined> {
const noteIDs = item.getNotes();
const notes = Zotero.Items.get(noteIDs).filter((note) => this.isAnnotationNote(note.getNote()));
if (notes.length === 0) return undefined;
return notes.reduce((max, current) =>
current.dateModified > max.dateModified ? current : max
);
}
private async removeExistingNotes(item: Zotero.Item): Promise<void> {
try {
const noteIDs = item.getNotes();
const notes = Zotero.Items.get(noteIDs).filter((note) =>
this.isAnnotationNote(note.getNote())
);
for (const note of notes) {
await Zotero.Items.trashTx(note.id);
Zotero.OrgExportAnnotations.log(`Removed old annotation note: ${note.id}`);
}
} catch (error) {
Zotero.OrgExportAnnotations.error("Failed to remove existing notes", error as Error);
}
}
private getAttachmentLastModifiedAt(attachment: Zotero.Item): string | undefined {
if (!this.isAttachmentWithExtractableAnnotations(attachment)) {
return undefined;
}
const annotations = attachment.getAnnotations();
if (!annotations || annotations.length === 0) return undefined;
return annotations.reduce((max, current) =>
current.dateModified > max.dateModified ? current : max
).dateModified;
}
async needsOrgSync(attachment: Zotero.Item): Promise<boolean> {
if (!attachment.parentItem) return false;
const note = await this.getLastAnnotationsNote(attachment.parentItem);
const annotationDateModified = this.getAttachmentLastModifiedAt(attachment);
if (note && annotationDateModified) {
return annotationDateModified > note.dateModified;
}
if (annotationDateModified && !note) {
return true;
}
return false;
}
private async createNote(attachment: Zotero.Item): Promise<Zotero.Item | undefined> {
try {
const ZoteroPane = Zotero.getActiveZoteroPane();
const note = await ZoteroPane.addNoteFromAnnotationsForAttachment(attachment, {
skipSelect: true,
});
Zotero.OrgExportAnnotations.log(`Created note from annotations: ${note?.id}`);
return note;
} catch (error) {
Zotero.OrgExportAnnotations.error("Failed to create note", error as Error);
throw error;
}
}
private existsOrgAttachment(parentItem: Zotero.Item, path: string): boolean {
try {
const attachmentIDs = parentItem.getAttachments();
const attachments = Zotero.Items.get(attachmentIDs);
return attachments.some((a) => a.attachmentPath === path);
} catch (error) {
Zotero.OrgExportAnnotations.error(
"Failed to check for existing org attachment",
error as Error
);
return false;
}
}
async exportAttachment(attachment: Zotero.Item): Promise<boolean> {
Zotero.OrgExportAnnotations.warn(`exportAttachment called: id=${attachment.id}`);
if (!this.isAttachmentWithExtractableAnnotations(attachment)) {
return false;
}
if (!attachment.parentItem) {
Zotero.OrgExportAnnotations.warn("Attachment has no parent item");
return false;
}
const notesPath = this.prefs.notesPath;
Zotero.OrgExportAnnotations.warn(`notesPath = "${notesPath}"`);
if (!notesPath) {
throw new Error("Notes path is not configured");
}
if (!Zotero.BetterNotes?.api?.convert?.note2md) {
throw new Error("Better Notes plugin is required but not found");
}
await this.prefs.ensureNotesDirectory();
await this.removeExistingNotes(attachment.parentItem);
const note = await this.createNote(attachment);
if (!note) {
Zotero.OrgExportAnnotations.warn("Failed to create note for attachment");
return false;
}
const title = attachment.parentItem.getField("title") as string;
const citationKey = attachment.parentItem.getField("citationKey") as string;
Zotero.OrgExportAnnotations.warn(`title="${title}", citationKey="${citationKey}"`);
if (!citationKey) {
Zotero.OrgExportAnnotations.warn(`Item "${title}" has no citation key, skipping`);
return false;
}
const orgPath = PathUtils.join(notesPath, `${citationKey}.org`);
Zotero.OrgExportAnnotations.warn(`Converting to org at: ${orgPath}`);
await convertToOrg(note, notesPath, orgPath, title, this.prefs.pandocPath);
if (this.prefs.attachOrgFile && !this.existsOrgAttachment(attachment.parentItem, orgPath)) {
await Zotero.Attachments.linkFromFile({
file: orgPath,
parentItemID: attachment.parentItem.id,
contentType: "text/org",
title: `${title} (Org)`,
});
}
Zotero.OrgExportAnnotations.warn(`Exported to ${orgPath}`);
return true;
}
async exportItem(item: Zotero.Item): Promise<boolean> {
Zotero.OrgExportAnnotations.warn(
`exportItem called: id=${item.id}, isAttachment=${item.isAttachment()}, isRegularItem=${item.isRegularItem()}`
);
if (item.isAttachment()) {
return this.exportAttachment(item);
}
if (!item.isRegularItem()) {
Zotero.OrgExportAnnotations.warn(`Item ${item.id}: not a regular item, skipping`);
return false;
}
const attachmentIDs = item.getAttachments();
const attachments = Zotero.Items.get(attachmentIDs);
Zotero.OrgExportAnnotations.warn(
`Item ${item.id}: found ${attachments.length} attachments (IDs: ${attachmentIDs.join(", ")})`
);
let exported = false;
for (const attachment of attachments) {
if (await this.exportAttachment(attachment)) {
exported = true;
}
}
return exported;
}
async listAllAttachments(): Promise<Zotero.Item[]> {
try {
const search = new Zotero.Search();
search.libraryID = Zotero.Libraries.userLibraryID;
search.addCondition("itemType", "is", "attachment");
const itemIDs = await search.search();
const uniqueIDs = [...new Set(itemIDs)];
const items = Zotero.Items.get(uniqueIDs);
return items
.filter((item) => this.isAttachmentWithExtractableAnnotations(item))
.filter((item) => item.parentItem !== undefined);
} catch (error) {
Zotero.OrgExportAnnotations.error("Failed to list attachments", error as Error);
return [];
}
}
async syncAll(): Promise<number> {
const attachments = await this.listAllAttachments();
let count = 0;
for (const attachment of attachments) {
const needsSync = await this.needsOrgSync(attachment);
if (needsSync) {
try {
if (await this.exportAttachment(attachment)) {
count++;
}
} catch (error) {
Zotero.OrgExportAnnotations.error(
`Failed to export attachment ${attachment.id}`,
error as Error
);
}
}
}
Zotero.OrgExportAnnotations.log(`Synced ${count} attachments`);
return count;
}
async forceExportAll(): Promise<number> {
const attachments = await this.listAllAttachments();
let count = 0;
for (const attachment of attachments) {
try {
if (await this.exportAttachment(attachment)) {
count++;
}
} catch (error) {
Zotero.OrgExportAnnotations.error(
`Failed to force export attachment ${attachment.id}`,
error as Error
);
}
}
Zotero.OrgExportAnnotations.log(`Force exported ${count} attachments`);
return count;
}
}

159
src/modules/menu.ts Normal file
View File

@@ -0,0 +1,159 @@
import type { OrgExportAnnotationsAddon } from "../addon";
const MENU_IDS = {
toolsExportAll: "orgexportannotations-menu-tools-export-all",
itemExportSelected: "orgexportannotations-menu-item-export",
itemSeparator: "orgexportannotations-menu-separator",
};
// Track registered menu IDs for Zotero 8 MenuManager API
let registeredMenuIDs: string[] = [];
export function registerMenus(addon: OrgExportAnnotationsAddon, window: Window): void {
// Use new MenuManager API if available (Zotero 8+)
if (Zotero.MenuManager) {
registerMenusZotero8(addon);
return;
}
// Fall back to DOM manipulation for Zotero 7
registerMenusZotero7(addon, window);
}
function registerMenusZotero8(addon: OrgExportAnnotationsAddon): void {
// Guard against double registration
if (registeredMenuIDs.length > 0) {
addon.log("MenuManager menus already registered, skipping");
return;
}
try {
// Register item context menu
Zotero.MenuManager!.registerMenu({
menuID: MENU_IDS.itemExportSelected,
pluginID: addon.id,
target: "main/library/item",
menus: [
{
menuType: "menuitem",
icon: addon.rootURI + "content/icons/menu-icon.svg",
onShowing: (_event, context) => {
context.menuElem?.setAttribute("label", "Export Annotations to Org");
},
onCommand: () => Zotero.OrgExportAnnotations.hooks.onMenuExportSelected(),
},
],
});
registeredMenuIDs.push(MENU_IDS.itemExportSelected);
addon.log("Registered item context menu via MenuManager");
// Register Tools menu item
Zotero.MenuManager!.registerMenu({
menuID: MENU_IDS.toolsExportAll,
pluginID: addon.id,
target: "main/menubar/tools",
menus: [
{
menuType: "menuitem",
icon: addon.rootURI + "content/icons/menu-icon.svg",
onShowing: (_event, context) => {
context.menuElem?.setAttribute("label", "Export All Annotations to Org");
},
onCommand: () => Zotero.OrgExportAnnotations.hooks.onMenuExportAll(),
},
],
});
registeredMenuIDs.push(MENU_IDS.toolsExportAll);
addon.log("Registered Tools menu item via MenuManager");
} catch (error) {
addon.error("Failed to register menus via MenuManager", error as Error);
}
}
function registerMenusZotero7(addon: OrgExportAnnotationsAddon, window: Window): void {
const doc = window.document;
// Guard against double registration (onStartup and onMainWindowLoad may both fire)
if (doc.getElementById(MENU_IDS.itemExportSelected)) {
addon.log("Menu items already registered, skipping");
return;
}
// Item context menu — DOM append to zotero-itemmenu
const itemMenu = doc.getElementById("zotero-itemmenu");
if (itemMenu) {
const separator = doc.createXULElement("menuseparator");
separator.id = MENU_IDS.itemSeparator;
itemMenu.appendChild(separator);
const menuItem = createMenuItem(doc, {
id: MENU_IDS.itemExportSelected,
label: "Export Annotations to Org",
icon: addon.rootURI + "content/icons/menu-icon.svg",
oncommand: () => Zotero.OrgExportAnnotations.hooks.onMenuExportSelected(),
});
itemMenu.appendChild(menuItem);
addon.log("Added item context menu item");
} else {
addon.warn("Could not find zotero-itemmenu element");
}
// Tools menu — direct DOM append
const toolsMenu = doc.getElementById("menu_ToolsPopup");
if (toolsMenu) {
const menuItem = createMenuItem(doc, {
id: MENU_IDS.toolsExportAll,
label: "Export All Annotations to Org",
icon: addon.rootURI + "content/icons/menu-icon.svg",
oncommand: () => Zotero.OrgExportAnnotations.hooks.onMenuExportAll(),
});
toolsMenu.appendChild(menuItem);
addon.log("Added Tools menu item");
} else {
addon.warn("Could not find menu_ToolsPopup element");
}
}
export function unregisterMenus(window: Window): void {
// For Zotero 8+, MenuManager handles cleanup automatically when plugin is disabled
// But we can manually unregister if needed
if (Zotero.MenuManager && registeredMenuIDs.length > 0) {
for (const menuID of registeredMenuIDs) {
try {
Zotero.MenuManager.unregisterMenu(menuID);
} catch (_error) {
// Menu may already be unregistered
}
}
registeredMenuIDs = [];
return;
}
// For Zotero 7, remove DOM elements
const doc = window.document;
const idsToRemove = Object.values(MENU_IDS);
for (const id of idsToRemove) {
const element = doc.getElementById(id);
element?.remove();
}
}
function createMenuItem(
doc: Document,
options: {
id: string;
label: string;
icon?: string;
oncommand: () => void;
}
): Element {
const menuItem = doc.createXULElement("menuitem");
menuItem.id = options.id;
menuItem.setAttribute("label", options.label);
if (options.icon) {
menuItem.setAttribute("class", "menuitem-iconic");
menuItem.setAttribute("image", options.icon);
}
menuItem.addEventListener("command", options.oncommand);
return menuItem;
}

73
src/modules/notifier.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { OrgExportAnnotationsAddon } from "../addon";
export function registerNotifiers(addon: OrgExportAnnotationsAddon): string[] {
const ids: string[] = [];
const syncNotifierID = Zotero.Notifier.registerObserver(
{
notify: async (
event: string,
type: string,
_ids: number[],
_extraData: Record<string, unknown>
) => {
if (event === "finish" && type === "sync") {
addon.log("Sync completed, checking for exports...");
if (addon.prefs.autoExportOnSync) {
try {
await addon.exporter.syncAll();
} catch (error) {
addon.error("Auto-export after sync failed", error as Error);
}
}
}
},
},
["sync"],
"orgexportannotations-sync"
);
ids.push(syncNotifierID);
const tabNotifierID = Zotero.Notifier.registerObserver(
{
notify: async (
event: string,
type: string,
ids: number[],
extraData: Record<string, unknown>
) => {
if (event === "close" && type === "tab") {
if (!addon.prefs.exportOnTabClose) return;
addon.log("Tab closed, checking for export...");
for (const tabId of ids) {
try {
const tabInfo = extraData[tabId] as { itemID?: number } | undefined;
if (tabInfo?.itemID) {
const item = Zotero.Items.get(tabInfo.itemID);
if (item) {
await addon.exporter.exportItem(item);
}
}
} catch (error) {
addon.error(`Failed to export on tab close: ${tabId}`, error as Error);
}
}
}
},
},
["tab"],
"orgexportannotations-tab"
);
ids.push(tabNotifierID);
addon.log(`Registered ${ids.length} notifiers`);
return ids;
}
export function unregisterNotifiers(ids: string[]): void {
for (const id of ids) {
Zotero.Notifier.unregisterObserver(id);
}
}

103
src/modules/prefs.ts Normal file
View File

@@ -0,0 +1,103 @@
const PREF_PREFIX = "extensions.zotero.orgexportannotations";
export class Prefs {
private getPref<T>(key: string, defaultValue: T): T {
const fullKey = `${PREF_PREFIX}.${key}`;
const value = Zotero.Prefs.get(fullKey, true);
return (value as T) ?? defaultValue;
}
private setPref<T>(key: string, value: T): void {
const fullKey = `${PREF_PREFIX}.${key}`;
Zotero.Prefs.set(fullKey, value, true);
}
get notesPath(): string {
const path = this.getPref("notesPath", "");
if (path && path.startsWith("~")) {
try {
// Use Gecko's directory service to get the actual home directory
const homeDir = Services.dirsvc.get("Home", Ci.nsIFile).path;
return path.replace("~", homeDir);
} catch (_error) {
// Fallback: strip the Zotero profile-specific suffix from profileDir
const home = PathUtils.profileDir.replace(/[/\\][^/\\]+[/\\][^/\\]+$/, "");
return path.replace("~", home);
}
}
return path;
}
set notesPath(value: string) {
this.setPref("notesPath", value);
}
get pandocPath(): string {
return this.getPref("pandocPath", "pandoc");
}
set pandocPath(value: string) {
this.setPref("pandocPath", value);
}
get attachOrgFile(): boolean {
return this.getPref("attachOrgFile", false);
}
set attachOrgFile(value: boolean) {
this.setPref("attachOrgFile", value);
}
get autoExportOnSync(): boolean {
return this.getPref("autoExportOnSync", true);
}
set autoExportOnSync(value: boolean) {
this.setPref("autoExportOnSync", value);
}
get exportOnTabClose(): boolean {
return this.getPref("exportOnTabClose", true);
}
set exportOnTabClose(value: boolean) {
this.setPref("exportOnTabClose", value);
}
get showNotification(): boolean {
return this.getPref("showNotification", true);
}
set showNotification(value: boolean) {
this.setPref("showNotification", value);
}
get debug(): boolean {
return this.getPref("debug", false);
}
set debug(value: boolean) {
this.setPref("debug", value);
}
async isNotesPathValid(): Promise<boolean> {
const path = this.notesPath;
if (!path) return false;
try {
return await IOUtils.exists(path);
} catch {
return false;
}
}
async ensureNotesDirectory(): Promise<void> {
const path = this.notesPath;
if (!path) {
throw new Error("Notes path is not configured");
}
await IOUtils.makeDirectory(path, { ignoreExisting: true });
const attachmentsPath = PathUtils.join(path, "attachments");
await IOUtils.makeDirectory(attachmentsPath, { ignoreExisting: true });
}
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"outDir": ".scaffold/build/addon/content/scripts",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["zotero-types"]
},
"include": ["src/**/*", "typings/**/*"],
"exclude": ["node_modules", ".scaffold"]
}

109
typings/global.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
declare const __env__: "development" | "production";
declare namespace Zotero {
let OrgExportAnnotations: import("../src/addon").OrgExportAnnotationsAddon & {
hooks: import("../src/hooks").Hooks;
};
const BetterNotes:
| {
api: {
convert: {
note2md(note: Zotero.Item, outputDir: string): Promise<string>;
};
};
}
| undefined;
}
interface Window {
OrgExportAnnotationsPrefs?: {
init(): void;
browseNotesPath(): Promise<void>;
browsePandocPath(): Promise<void>;
testPandoc(): Promise<void>;
exportAllNow(): Promise<void>;
forceExportAllNow(): Promise<void>;
};
}
interface MenuManagerMenu {
menuType: "menuitem" | "separator" | "submenu";
l10nID?: string;
label?: string;
icon?: string;
onCommand?: (event: Event, context: MenuManagerContext) => void;
onShowing?: (event: Event, context: MenuManagerContext) => void;
menus?: MenuManagerMenu[];
}
interface MenuManagerContext {
collectionTreeRow?: unknown;
items?: Zotero.Item[];
tabType?: string;
tabSubType?: string;
tabID?: string;
menuElem?: Element;
setL10nArgs?(l10nArgs: string): void;
setEnabled?(enabled: boolean): void;
setVisible?(visible: boolean): void;
setIcon?(icon: string, darkIcon?: string): void;
}
interface MenuManagerRegistration {
menuID: string;
pluginID: string;
target: string;
menus: MenuManagerMenu[];
}
declare namespace Zotero {
const MenuManager:
| {
registerMenu(options: MenuManagerRegistration): void;
unregisterMenu(menuID: string): void;
updateMenuPopup(
menu: Element,
target: string,
options: { getContext: () => MenuManagerContext }
): void;
}
| undefined;
}
declare namespace IOUtils {
function remove(path: string): Promise<void>;
function exists(path: string): Promise<boolean>;
function makeDirectory(path: string, options?: { ignoreExisting?: boolean }): Promise<void>;
}
declare namespace PathUtils {
function normalize(path: string): string;
function join(...parts: string[]): string;
function filename(path: string): string;
const profileDir: string;
}
// Gecko/XPCOM globals available in Zotero 7 runtime
declare const Services: {
dirsvc: {
get(prop: string, iface: unknown): { path: string };
};
io: {
newURI(spec: string): unknown;
};
scriptloader: {
loadSubScript(url: string): void;
};
};
declare const Ci: {
nsIFile: unknown;
amIAddonManagerStartup: unknown;
};
declare const Cc: {
[contractID: string]: {
getService(iface: unknown): unknown;
};
};

36
typings/i10n.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
// Generated by zotero-plugin-scaffold
/* prettier-ignore */
/* eslint-disable */
// @ts-nocheck
export type FluentMessageId =
| 'description'
| 'error-better-notes-required'
| 'error-notes-path-invalid'
| 'error-notes-path-not-set'
| 'error-pandoc-not-found'
| 'menu-export-all'
| 'menu-export-selected'
| 'menu-preferences'
| 'name'
| 'notification-config-required'
| 'notification-export-complete'
| 'notification-export-error'
| 'notification-no-items'
| 'prefs-attach-org'
| 'prefs-attach-org-description'
| 'prefs-auto-export-sync'
| 'prefs-browse'
| 'prefs-debug'
| 'prefs-export-now'
| 'prefs-export-tab-close'
| 'prefs-force-export'
| 'prefs-notes-path'
| 'prefs-notes-path-description'
| 'prefs-pandoc-path'
| 'prefs-pandoc-path-description'
| 'prefs-section-advanced'
| 'prefs-section-behavior'
| 'prefs-section-paths'
| 'prefs-show-notification'
| 'prefs-test-pandoc'
| 'prefs-title';

19
typings/prefs.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
// Generated by zotero-plugin-scaffold
/* prettier-ignore */
/* eslint-disable */
// @ts-nocheck
// prettier-ignore
declare namespace _ZoteroTypes {
interface Prefs {
PluginPrefsMap: {
"notesPath": string;
"pandocPath": string;
"attachOrgFile": boolean;
"autoExportOnSync": boolean;
"exportOnTabClose": boolean;
"showNotification": boolean;
"debug": boolean;
};
}
}

28
zotero-plugin.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from "zotero-plugin-scaffold";
import pkg from "./package.json";
export default defineConfig({
source: ["src", "addon"],
dist: ".scaffold/build",
name: pkg.config.addonName,
id: pkg.config.addonID,
namespace: pkg.config.addonRef,
build: {
esbuildOptions: [
{
entryPoints: ["src/index.ts"],
bundle: true,
target: "firefox128",
outfile: ".scaffold/build/addon/content/scripts/index.js",
},
],
},
server: {
asProxy: true,
},
release: {
bumpp: {
execute: "npm run build",
},
},
});