diff --git a/src/ZoteroAnnotation.ts b/src/ZoteroAnnotation.ts new file mode 100644 index 0000000..cc9c335 --- /dev/null +++ b/src/ZoteroAnnotation.ts @@ -0,0 +1,47 @@ +import type { ZoteroResponse } from "./ZoteroResponse"; +import { ZoteroItem } from "./ZoteroItem"; + +export class ZoteroAnnotation { + + constructor( + public key: string, + public attachmentItemKey: string, + public parentItemKey: string, + public parentItem?: ZoteroItem, + public text: string = "", + public comment: string = "", + public color: string = "", + public pageLabel: string = "", + ){} + + static fromResponse(item: ZoteroResponse): ZoteroAnnotation { + return new ZoteroAnnotation( + item.key ?? "", + item.data?.parentItem ?? "", + "", + undefined, + item.data?.annotationText ?? "", + item.data?.annotationComment ?? "", + item.data?.annotationColor ?? "", + item.data?.pageLabel ?? "", + ); + } + + toString(): string { + return [ + this.key, + this.text, + this.comment, + this.color, + this.pageLabel, + this.parentItem?.title, + this.parentItem?.creators, + this.parentItem?.year, + this.parentItemKey, + this.attachmentItemKey, + ] + .filter(Boolean) + .join(" "); + } + +} diff --git a/src/ZoteroItem.ts b/src/ZoteroItem.ts new file mode 100644 index 0000000..d8ef8b8 --- /dev/null +++ b/src/ZoteroItem.ts @@ -0,0 +1,64 @@ +import type { ZoteroResponse } from "./ZoteroResponse"; + +export class ZoteroItem { + constructor( + public title: string, + public year: string, + public creators: string, + public key: string, + public citation: string, + public bibliography: string, + ) {} + + static fromResponse(item: ZoteroResponse): ZoteroItem { + const title = item.data?.title ?? "Untitled"; + const date = item.data?.date; + const yearMatch = date?.match(/\b\d{4}\b/); + const year = yearMatch ? yearMatch[0] : "n.d."; + const creators = this.formatCreators(item.data?.creators); + const key = item.key ?? "No Key"; + const citation = this.htmlToPlainText(item.citation ?? ""); + const bibliography = this.htmlToPlainText(item.bib ?? ""); + return new ZoteroItem(title, year, creators, key, citation, bibliography); + } + + private static formatCreators( + creators?: Array<{ + firstName?: string; + lastName?: string; + name?: string; + }> + ): string { + if (!creators || creators.length === 0) return "Unknown author"; + + const names = creators.map(c => { + if (c.name) return c.name; + if (c.firstName && c.lastName) return `${c.firstName} ${c.lastName}`; + if (c.lastName) return c.lastName; + return "Unknown"; + }); + + return names.join(" & "); + } + + private static htmlToPlainText(html: string): string { + const div = document.createElement("div"); + div.innerHTML = html; + + return div.textContent?.trim() ?? ""; + } + + toString(): string { + return [this.title, this.year, this.creators, this.key, this.citation, this.bibliography] + .filter(Boolean) + .join(" "); + } + + toStringShort(): string { + return [this.creators, this.year] + .filter(Boolean) + .join(", "); + } + +} + diff --git a/src/ZoteroResponse.ts b/src/ZoteroResponse.ts new file mode 100644 index 0000000..7cc482a --- /dev/null +++ b/src/ZoteroResponse.ts @@ -0,0 +1,23 @@ +export type ZoteroResponse = { + key: string; + version: number; + links?: Record; + meta?: Record; + citation?: string; + bib?: string; + data?: { + title?: string; + date?: string; + itemType?: string; + parentItem?: string; + annotationText?: string; + annotationComment?: string; + annotationColor?: string; + pageLabel?: string; + creators?: Array<{ + firstName?: string; + lastName?: string; + name?: string; + }>; + }; +}; diff --git a/src/clients.ts b/src/clients.ts index 2feb473..944412f 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -1,29 +1,14 @@ import { Notice, requestUrl } from 'obsidian'; - -type ZoteroItemResponse = { - key: string; - version: number; - links?: Record; - meta?: Record; - citation?: string; - bib?: string; - data?: { - title?: string; - date?: string; - creators?: Array<{ - firstName?: string; - lastName?: string; - name?: string; - }>; - }; -}; +import { ZoteroItem } from './ZoteroItem'; +import { ZoteroAnnotation } from './ZoteroAnnotation'; +import type { ZoteroResponse } from './ZoteroResponse'; export class ZoteroClient { private userID: number; private baseURL: string; private style?: string; - private baseParams: URLSearchParams; + private params: URLSearchParams; constructor(port: string); constructor(port: string, style: string); @@ -31,15 +16,12 @@ export class ZoteroClient { this.userID = 0; this.baseURL = `http://127.0.0.1:${port}/api/users/${this.userID}`; this.style = style; - this.baseParams = new URLSearchParams(); - if (this.style) { - this.baseParams.set("style", this.style); - } - this.baseParams.set("format", "json"); - this.baseParams.set("include", "citation,bib,data"); + this.params = new URLSearchParams(); + this.params.set("format", "json"); } private async request(url: string): Promise { + console.log(url); const response = await requestUrl({ url, method: "GET", @@ -51,7 +33,6 @@ export class ZoteroClient { }, }); - if (response.status !== 200) { new Notice(`Zotero error ${response.status}: ${response.text}`); throw new Error(`Zotero request failed: ${response.status} ${response.text}`); @@ -66,14 +47,80 @@ export class ZoteroClient { } - async getItems(): Promise { - const url = `${this.baseURL}/items?${this.baseParams.toString()}`; - return this.request(url); + async getItems(): Promise { + if (this.style) { + this.params.set("style", this.style); + } + this.params.set("include", "citation,bib,data"); + const url = `${this.baseURL}/items?${this.params.toString()}`; + const items = await this.request(url); + return items.map(item => ZoteroItem.fromResponse(item)); } - async getItemsTop(): Promise { - const url = `${this.baseURL}/items/top?${this.baseParams.toString()}`; - return this.request(url); + async getItemsTop(): Promise { + if (this.style) { + this.params.set("style", this.style); + } + this.params.set("include", "citation,bib,data"); + const url = `${this.baseURL}/items/top?${this.params.toString()}`; + const items = await this.request(url); + return items.map(item => ZoteroItem.fromResponse(item)); } -} + private async getItemByKey(itemKey: string, include: string = "data"): Promise { + const params = new URLSearchParams(); + params.set("format", "json"); + params.set("include", include); + + if (this.style && include.includes("citation")) { + params.set("style", this.style); + } + + const url = `${this.baseURL}/items/${itemKey}?${params.toString()}`; + return await this.request(url); + } + + private async resolveParentItem(annotation: ZoteroAnnotation): Promise { + if (!annotation.attachmentItemKey) return; + + const attachmentOrParent = await this.getItemByKey(annotation.attachmentItemKey, "citation,bib,data"); + + const parentItemKey = attachmentOrParent.data?.parentItem; + + if (!parentItemKey) { + annotation.parentItemKey = attachmentOrParent.key; + const fullItem = await this.getItemByKey(attachmentOrParent.key, "citation,bib,data"); + annotation.parentItem = ZoteroItem.fromResponse(fullItem); + return; + } + + annotation.parentItemKey = parentItemKey; + const fullParentItem = await this.getItemByKey(parentItemKey, "citation,bib,data"); + annotation.parentItem = ZoteroItem.fromResponse(fullParentItem); + } + + async getAnnotations(): Promise { + this.params.set("include", "data"); + this.params.set("itemType", "annotation"); + + const url = `${this.baseURL}/items?${this.params.toString()}`; + const responses = await this.request(url); + + this.params.delete("itemType"); + + const annotations = responses + .map(annotation => ZoteroAnnotation.fromResponse(annotation)) + .filter(a => a.text && a.text.trim() !== ""); + + for (const annotation of annotations) { + try { + await this.resolveParentItem(annotation); + } catch (error) { + console.error(`Failed to resolve parent item for annotation ${annotation.key}`, error); + } + } + + return annotations; + } + + } diff --git a/src/main.ts b/src/main.ts index 3204044..86e63ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ import { App, Editor, MarkdownView, Modal, Plugin, Notice } from 'obsidian'; -import { DEFAULT_SETTINGS, CiteSettings, CiteSettingTab } from "./settings"; -import { CiteModal, OpenModal } from "./modals" +import { DEFAULT_SETTINGS, Settings, SettingTab } from "./settings"; +import { CiteModal, OpenModal, AnnotationModal } from "./modals" export default class Cite extends Plugin { - settings: CiteSettings; + settings: Settings; async onload() { @@ -71,7 +71,18 @@ export default class Cite extends Plugin { }, }); - this.addSettingTab(new CiteSettingTab(this.app, this)); + this.addCommand({ + id: 'annotation-command', + name: 'insert annotations', + editorCallback: (editor: Editor, view: MarkdownView) => { + const selection = editor.getSelection(); + new AnnotationModal(this.app, this.settings.portSetting, this.settings.linkSetting, this.settings.styleSetting, (annotations: string) => { + editor.replaceSelection(annotations); + }).open(); + } + }); + + this.addSettingTab(new SettingTab(this.app, this)); this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); @@ -81,7 +92,7 @@ export default class Cite extends Plugin { } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial); } async saveSettings() { diff --git a/src/modals.ts b/src/modals.ts index ad998bf..ec67bc5 100644 --- a/src/modals.ts +++ b/src/modals.ts @@ -1,12 +1,134 @@ import { shell } from "electron"; import { App, FuzzyMatch, FuzzySuggestModal, renderResults, Notice } from 'obsidian'; import { ZoteroClient } from "./clients"; -import type { ZoteroItemResponse } from "./clients"; +import { ZoteroItem } from "./ZoteroItem"; +import { ZoteroAnnotation } from "./ZoteroAnnotation"; -export class OpenModal extends FuzzySuggestModal { +const ZOTERO_BASE_URL = "zotero://select/library/items/"; + +function renderZoteroItem( + match: FuzzyMatch, + el: HTMLElement +) { + const { item } = match; + + const titleEl = el.createDiv(); + renderResults(titleEl, item.title, match.match); + + const subtitleEl = el.createEl("small"); + const offset = -(item.title.length + 1); + renderResults( + subtitleEl, + `${item.creators}, ${item.year}`, + match.match, + offset + ); +} + +function renderZoteroAnnotation( + match: FuzzyMatch, + el: HTMLElement +) { + const { item: annotation } = match; + + const titleEl = el.createDiv(); + renderResults(titleEl, annotation.text, match.match); + + const subtitleEl = el.createEl("small"); + const offset = -(annotation.text.length + 1); + renderResults(subtitleEl, annotation.parentItem.toStringShort(), match.match, offset); +} + +async function loadItems( + modal: FuzzySuggestModal, + client: ZoteroClient, +): Promise { + try { + const items = await client.getItemsTop(); + modal.setPlaceholder("Search Zotero..."); + modal.inputEl.disabled = false; + modal.inputEl.focus(); + modal.updateSuggestions(); + return items; + } catch (error) { + new Notice( + "Failed to load citations/bibliographies from Zotero." + ); + console.error(error); + modal.close(); + return []; + } +} + +async function loadAnnotations( + client: ZoteroClient, +): Promise { + + try { + const annotations = await client.getAnnotations(); + return annotations; + } catch (error) { + new Notice( + "Failed to load annotations from Zotero." + ); + console.error(error); + return []; + } +} + +export class AnnotationModal extends FuzzySuggestModal { private client: ZoteroClient; - private items: ZoteroItemResponse[] = []; - private zoteroBaseUrl; + private annotations: ZoteroAnnotation[] = []; + private createLink: boolean; + private onChoose: (annotations: string) => void; + + constructor( + app: App, + port: string, + createLink: boolean, + style: string, + onChoose: (annotations: string) => void, + ) { + super(app); + this.client = new ZoteroClient(port, style); + this.setPlaceholder("Loading..."); + this.createLink = createLink + this.onChoose = onChoose; + } + + async onOpen(){ + this.annotations = await loadAnnotations(this.client); + super.onOpen(); + } + + getItems(): ZoteroAnnotation[] { + return this.annotations; + } + + getItemText(annotation: ZoteroAnnotation): string { + return annotation.toString(); + } + + renderSuggestion(match: FuzzyMatch, el: HTMLElement) { + renderZoteroAnnotation(match, el); + } + + async onChooseItem(annotation: ZoteroAnnotation) { + + let citation = annotation.parentItem.citation; + + if (this.createLink) { + citation = `[${citation}](${ZOTERO_BASE_URL}${annotation.parentItemKey})`; + } + + this.onChoose(`"${annotation.text}" ${citation}`); + } + +} + +export class OpenModal extends FuzzySuggestModal { + private client: ZoteroClient; + private items: ZoteroItem[] = []; constructor( app: App, @@ -14,93 +136,39 @@ export class OpenModal extends FuzzySuggestModal { ) { super(app); this.client = new ZoteroClient(port); - this.setPlaceholder("Loading references..."); - this.zoteroBaseUrl = "zotero://select/library/items/"; + this.setPlaceholder("Loading..."); } async onOpen(){ + this.items = await loadItems(this, this.client); super.onOpen(); - - try { - this.items = await this.client.getItemsTop(); - this.setPlaceholder("Search Zotero..."); - this.inputEl.disabled = false; - this.inputEl.focus(); - this.updateSuggestions(); - } catch (error) { - new Notice("Failed to load citations/bibliographies from Zotero. Maybe you forgot to turn on or configure Zotero."); - console.error(error); - this.close(); - } } - - private getTitle(item: ZoteroItemResponse): string { - return item.data?.title ?? "Untitled"; - } - - private getYear(item: ZoteroItemResponse): string { - const date = item.data?.date; - if (!date) return "n.d."; - - const match = date.match(/\b\d{4}\b/); - return match ? match[0] : "n.d."; - } - - private getAuthor(item: ZoteroItemResponse): string { - const creators = item.data?.creators; - if (!creators || creators.length === 0) return "Unknown author"; - - const first = creators[0]; - - if (first.name) return first.name; - if (first.firstName && first.lastName) return `${first.firstName} ${first.lastName}`; - if (first.lastName) return first.lastName; - - return "Unknown author"; - } - - private getKey(item: ZoteroItemResponse): string { - return item.key ?? ""; - } - - getItems(): ZoteroItemResponse[] { + getItems(): ZoteroItem[] { return this.items; } - getItemText(item: ZoteroItemResponse): string { - return `${this.getTitle(item)} ${this.getAuthor(item)} ${this.getYear(item)}`; + getItemText(item: ZoteroItem): string { + return item.toString(); } - renderSuggestion(match: FuzzyMatch, el: HTMLElement) { - const { item } = match; - - const title = this.getTitle(item); - const author = this.getAuthor(item); - const year = this.getYear(item); - - const titleEl = el.createDiv(); - renderResults(titleEl, title, match.match); - - const subtitleEl = el.createEl("small"); - const offset = -(title.length + 1); - renderResults(subtitleEl, `${author}, ${year}`, match.match, offset); + renderSuggestion(match: FuzzyMatch, el: HTMLElement) { + renderZoteroItem(match, el); } - onChooseItem(item: ZoteroItemResponse) { - if (!this.getKey(item)) return; - const uri = `${this.zoteroBaseUrl}${this.getKey(item)}`; + onChooseItem(item: ZoteroItem) { + if (!item.key) return; + const uri = `${ZOTERO_BASE_URL}${item.key}`; shell.openExternal(uri); } } -export class CiteModal extends FuzzySuggestModal { +export class CiteModal extends FuzzySuggestModal { private onChoose: (reference: string) => void; private client: ZoteroClient; - private items: ZoteroItemResponse[] = []; - private zoteroBaseUrl: string; + private items: ZoteroItem[] = []; private createLink: boolean; private mode: string; @@ -113,106 +181,38 @@ export class CiteModal extends FuzzySuggestModal { onChoose: (reference: string) => void, ) { super(app); + this.setPlaceholder("Loading..."); this.client = new ZoteroClient(port, style); - this.onChoose = onChoose; - this.setPlaceholder("Loading citations..."); - this.zoteroBaseUrl = "zotero://select/library/items/"; this.createLink = createLink; this.mode = mode; + this.onChoose = onChoose; } async onOpen(){ + this.items = await loadItems(this, this.client); super.onOpen(); - - try { - if (this.mode === "citation"){ - this.items = await this.client.getItemsTop(); - } else if (this.mode === "bibliography"){ - this.items = await this.client.getItemsTop(); - } else { - throw new Error(`Unknown mode: ${this.mode}`); - } - - this.setPlaceholder("Search Zotero..."); - this.inputEl.disabled = false; - this.inputEl.focus(); - this.updateSuggestions(); - } catch (error) { - new Notice("Failed to load citations/bibliographies from Zotero. Maybe you forgot to turn on or configure Zotero."); - console.error(error); - this.close(); - } } - private getTitle(item: ZoteroItemResponse): string { - return item.data?.title ?? "Untitled"; - } - - private getYear(item: ZoteroItemResponse): string { - const date = item.data?.date; - if (!date) return "n.d."; - - const match = date.match(/\b\d{4}\b/); - return match ? match[0] : "n.d."; - } - - private getAuthor(item: ZoteroItemResponse): string { - const creators = item.data?.creators; - if (!creators || creators.length === 0) return "Unknown author"; - - const first = creators[0]; - - if (first.name) return first.name; - if (first.firstName && first.lastName) return `${first.firstName} ${first.lastName}`; - if (first.lastName) return first.lastName; - - return "Unknown author"; - } - - private getKey(item: ZoteroItemResponse): string { - return item.key ?? "No Key"; - } - - private htmlToPlainText(html: string): string { - const div = document.createElement("div"); - div.innerHTML = html; - - return div.textContent?.trim() ?? ""; - } - - getItems(): ZoteroItemResponse[] { + getItems(): ZoteroItem[] { return this.items; } - getItemText(item: ZoteroItemResponse): string { - return `${this.getTitle(item)} ${this.getAuthor(item)} ${this.getYear(item)}`; + getItemText(item: ZoteroItem): string { + return item.toString(); } - renderSuggestion(match: FuzzyMatch, el: HTMLElement) { - const { item } = match; - - const title = this.getTitle(item); - const author = this.getAuthor(item); - const year = this.getYear(item); - - const titleEl = el.createDiv(); - renderResults(titleEl, title, match.match); - - const subtitleEl = el.createEl("small"); - const offset = -(title.length + 1); - renderResults(subtitleEl, `${author}, ${year}`, match.match, offset); + renderSuggestion(match: FuzzyMatch, el: HTMLElement) { + renderZoteroItem(match, el); } - onChooseItem(item: ZoteroItemResponse) { + onChooseItem(item: ZoteroItem) { let output = ""; if (this.mode === "citation"){ - const raw = item.citation ?? ""; - output = this.htmlToPlainText(raw); + output = item.citation; } else if (this.mode === "bibliography"){ - const raw = item.bib ?? ""; - output = this.htmlToPlainText(raw); + output = item.bibliography; } else { throw new Error(`Unknown mode: ${this.mode}`); } @@ -220,8 +220,9 @@ export class CiteModal extends FuzzySuggestModal { if (!this.createLink) { this.onChoose(output); } else { - this.onChoose(`[${output}](${this.zoteroBaseUrl}${this.getKey(item)})`); + this.onChoose(`[${output}](${ZOTERO_BASE_URL}${item.key})`); } + } } diff --git a/src/settings.ts b/src/settings.ts index aa50450..0573099 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,18 +1,18 @@ import {App, PluginSettingTab, Setting } from "obsidian"; -export interface CiteSettings { +export interface Settings { portSetting: string; styleSetting: string; linkSetting: boolean; } -export const DEFAULT_SETTINGS: CiteSettings = { +export const DEFAULT_SETTINGS: Settings = { portSetting: '23119', styleSetting: 'apa', linkSetting: true } -export class CiteSettingTab extends PluginSettingTab { +export class SettingTab extends PluginSettingTab { plugin: Cite; constructor(app: App, plugin: Cite) {