kindle-zotero_importer/zotero-writer.js
2026-05-27 10:49:04 +02:00

156 lines
4.7 KiB
JavaScript

// Run in Zotero via Tools > Developer > Run JavaScript.
// Set PLAN_PATH to the generated import-plan.final.json path.
// Keep DRY_RUN=true until the preview output looks correct.
const PLAN_PATH = "/Users/ubd/Library/Mobile Documents/iCloud~md~obsidian/Documents/rhizome/06_projects/UTI/kindle-zotero-importer/import-plan.final.json";
const DRY_RUN = false;
const text = await Zotero.File.getContentsAsync(PLAN_PATH);
const plan = JSON.parse(text);
if (plan.format !== "kindle-zotero-importer.zotero-writer-plan.v1") {
throw new Error(`Unsupported plan format: ${plan.format}`);
}
const results = {
dryRun: DRY_RUN,
total: plan.annotations.length,
created: 0,
skippedExisting: 0,
updatedComments: 0,
removedDuplicates: 0,
failed: [],
preview: [],
};
const existingByAttachment = new Map();
function normalizePosition(position) {
if (!position) {
return "";
}
if (typeof position === "string") {
try {
return JSON.stringify(JSON.parse(position));
} catch (_error) {
return position;
}
}
return JSON.stringify(position);
}
function annotationFingerprint(annotation) {
return `${annotation.text || ""}\u0000${normalizePosition(annotation.position)}`;
}
function existingAnnotationFingerprint(annotation) {
return `${annotation.annotationText || ""}\u0000${normalizePosition(annotation.annotationPosition)}`;
}
async function getExistingAnnotations(attachment) {
if (!existingByAttachment.has(attachment.id)) {
const annotations = attachment.getAnnotations ? attachment.getAnnotations() : [];
const byFingerprint = new Map();
for (const annotation of annotations) {
const fingerprint = existingAnnotationFingerprint(annotation);
const kept = byFingerprint.get(fingerprint);
if (!kept) {
byFingerprint.set(fingerprint, annotation);
continue;
}
const keptHasComment = Boolean(kept.annotationComment);
const currentHasComment = Boolean(annotation.annotationComment);
const duplicate = keptHasComment || !currentHasComment ? annotation : kept;
const replacement = duplicate === annotation ? kept : annotation;
byFingerprint.set(fingerprint, replacement);
if (!DRY_RUN) {
await duplicate.eraseTx();
}
results.removedDuplicates += 1;
}
existingByAttachment.set(attachment.id, byFingerprint);
}
return existingByAttachment.get(attachment.id);
}
function mergeComments(existingComment, newComment) {
const seen = new Set();
const merged = [];
for (const comment of [existingComment, newComment]) {
for (const part of String(comment || "").split(/\n\s*\n/)) {
const cleaned = part.trim();
if (!cleaned || seen.has(cleaned)) {
continue;
}
seen.add(cleaned);
merged.push(cleaned);
}
}
return merged.join("\n\n");
}
for (const entry of plan.annotations) {
try {
const attachment = Zotero.Items.get(entry.attachment_item_id);
if (!attachment) {
throw new Error(`Attachment not found: ${entry.attachment_item_id}`);
}
const annotation = {
key: Zotero.DataObjectUtilities.generateKey(),
...entry.annotation,
};
if (!annotation.sortIndex) {
delete annotation.sortIndex;
}
const existingAnnotations = await getExistingAnnotations(attachment);
const fingerprint = annotationFingerprint(annotation);
const existingAnnotation = existingAnnotations.get(fingerprint);
if (existingAnnotation) {
const mergedComment = mergeComments(existingAnnotation.annotationComment, annotation.comment);
if (mergedComment && existingAnnotation.saveTx && existingAnnotation.annotationComment !== mergedComment) {
existingAnnotation.annotationComment = mergedComment;
if (!DRY_RUN) {
await existingAnnotation.saveTx();
}
results.updatedComments += 1;
}
results.skippedExisting += 1;
continue;
}
if (DRY_RUN) {
results.preview.push({
clipping_id: entry.clipping_id,
attachment_item_id: entry.attachment_item_id,
type: annotation.type,
text: annotation.text.slice(0, 120),
pageLabel: annotation.pageLabel,
sortIndex: annotation.sortIndex,
position: annotation.position,
});
continue;
}
await Zotero.Annotations.saveFromJSON(attachment, annotation);
existingAnnotations.set(fingerprint, {
annotationComment: annotation.comment || "",
annotationPosition: annotation.position,
annotationText: annotation.text || "",
});
results.created += 1;
} catch (error) {
results.failed.push({
clipping_id: entry.clipping_id,
attachment_item_id: entry.attachment_item_id,
message: String(error),
});
}
}
return JSON.stringify(results, null, 2);