CI / build (push) Successful in 21s
Bump strict_max_version to 10.0.* and esbuild target to firefox140 (Zotero 9/10 Firefox 140 ESR base). Update docs accordingly.
287 lines
7.8 KiB
Markdown
287 lines
7.8 KiB
Markdown
# 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–10
|
||
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));
|
||
```
|