chore: initial project commit
This commit is contained in:
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user