chore: initial project commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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
15
.eslintrc.js
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
85
.gitea/workflows/release.yml
Normal file
85
.gitea/workflows/release.yml
Normal 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
39
.gitignore
vendored
Normal 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
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
286
AGENTS.md
Normal file
286
AGENTS.md
Normal 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
211
README.md
Normal 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
70
addon/bootstrap.js
vendored
Normal 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() {}
|
||||||
1
addon/content/icons/icon.svg
Normal file
1
addon/content/icons/icon.svg
Normal 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 |
1
addon/content/icons/icon@2x.svg
Normal file
1
addon/content/icons/icon@2x.svg
Normal 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 |
1
addon/content/icons/menu-icon.svg
Normal file
1
addon/content/icons/menu-icon.svg
Normal 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 |
83
addon/content/preferences.xhtml
Normal file
83
addon/content/preferences.xhtml
Normal 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>
|
||||||
76
addon/content/scripts/preferences.js
Normal file
76
addon/content/scripts/preferences.js
Normal 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;
|
||||||
27
addon/locale/en-US/addon.ftl
Normal file
27
addon/locale/en-US/addon.ftl
Normal 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 }
|
||||||
40
addon/locale/en-US/preferences.ftl
Normal file
40
addon/locale/en-US/preferences.ftl
Normal 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
20
addon/manifest.json
Normal 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
7
addon/prefs.js
Normal 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
61
flake.lock
generated
Normal 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
72
flake.nix
Normal 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
4488
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal 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
53
src/addon.ts
Normal 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
157
src/hooks.ts
Normal 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
31
src/index.ts
Normal 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
58
src/modules/converter.ts
Normal 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
280
src/modules/exporter.ts
Normal 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
159
src/modules/menu.ts
Normal 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
73
src/modules/notifier.ts
Normal 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
103
src/modules/prefs.ts
Normal 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
25
tsconfig.json
Normal 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
109
typings/global.d.ts
vendored
Normal 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
36
typings/i10n.d.ts
vendored
Normal 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
19
typings/prefs.d.ts
vendored
Normal 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
28
zotero-plugin.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user