|
| 1 | +/* |
| 2 | + * Zotero Developer > Run JavaScript fixture for Vocab Flow color extraction. |
| 3 | + * |
| 4 | + * This script creates a temporary Zotero item with one PDF attachment and one |
| 5 | + * underline annotation for every supported Vocab Flow candidate color. It then |
| 6 | + * invokes the built Vocab Flow menu command path inside Zotero and verifies |
| 7 | + * that each color command produces candidates from only its matching annotation. |
| 8 | + * |
| 9 | + * Run inside Zotero, not Node: |
| 10 | + * Tools -> Developer -> Run JavaScript |
| 11 | + * paste this file, click Run |
| 12 | + */ |
| 13 | + |
| 14 | +const VOCAB_FLOW_COLOR_FIXTURE = { |
| 15 | + colors: [ |
| 16 | + { name: 'green', color: '#5fb236', marker: 'green rheology actuator' }, |
| 17 | + { name: 'yellow', color: '#ffd400', marker: 'yellow dielectric elastomer' }, |
| 18 | + { name: 'blue', color: '#2ea8e5', marker: 'blue piezoelectric polymer' }, |
| 19 | + { name: 'purple', color: '#a28ae5', marker: 'purple anisotropic hydrogel' }, |
| 20 | + { name: 'red', color: '#ff6666', marker: 'red valence actuator' }, |
| 21 | + { name: 'gray', color: '#aaaaaa', marker: 'gray ionic conductor' } |
| 22 | + ], |
| 23 | + repoRoot: globalThis.VOCAB_FLOW_REPO_ROOT ?? '/Users/choemun-yeong/workspace/projects/zotero/zotero-vocab-flow' |
| 24 | +}; |
| 25 | + |
| 26 | +async function runVocabFlowColorFixture() { |
| 27 | + const startedAt = new Date().toISOString(); |
| 28 | + const createdItemIDs = []; |
| 29 | + const generatedNoteIDs = []; |
| 30 | + let parentItem = null; |
| 31 | + let attachmentItem = null; |
| 32 | + |
| 33 | + function assert(condition, message) { |
| 34 | + if (!condition) throw new Error(message); |
| 35 | + } |
| 36 | + |
| 37 | + async function loadRuntimeExports() { |
| 38 | + let code = await IOUtils.readUTF8(`${VOCAB_FLOW_COLOR_FIXTURE.repoRoot}/addon/bootstrap.js`); |
| 39 | + code = code.replace( |
| 40 | + 'return __toCommonJS(bootstrap_exports);', |
| 41 | + [ |
| 42 | + 'bootstrap_exports.__runtimeTest = {', |
| 43 | + ' VocabFlowMenuManager,', |
| 44 | + ' readUnderlineTexts,', |
| 45 | + ' extractForItem,', |
| 46 | + ' generateVocabCandidates,', |
| 47 | + ' writeCandidateNote,', |
| 48 | + ' discardCandidateNote', |
| 49 | + '};', |
| 50 | + 'return __toCommonJS(bootstrap_exports);' |
| 51 | + ].join('\n') |
| 52 | + ); |
| 53 | + const VocabFlowRuntime = eval(code + '\nVocabFlowBootstrap;'); |
| 54 | + assert( |
| 55 | + VocabFlowRuntime.__runtimeTest?.VocabFlowMenuManager, |
| 56 | + `Vocab Flow runtime test exports not available; keys=${Object.keys(VocabFlowRuntime || {}).join(',')}` |
| 57 | + ); |
| 58 | + return VocabFlowRuntime.__runtimeTest; |
| 59 | + } |
| 60 | + |
| 61 | + async function saveItem(item) { |
| 62 | + const id = await item.saveTx(); |
| 63 | + createdItemIDs.push(id); |
| 64 | + return id; |
| 65 | + } |
| 66 | + |
| 67 | + async function createFixtureItem() { |
| 68 | + const libraryID = Zotero.Libraries.userLibraryID; |
| 69 | + parentItem = new Zotero.Item('journalArticle'); |
| 70 | + parentItem.libraryID = libraryID; |
| 71 | + parentItem.setField('title', `Vocab Flow color runtime fixture ${startedAt}`); |
| 72 | + const parentID = await saveItem(parentItem); |
| 73 | + |
| 74 | + attachmentItem = new Zotero.Item('attachment'); |
| 75 | + attachmentItem.libraryID = libraryID; |
| 76 | + attachmentItem.parentID = parentID; |
| 77 | + attachmentItem.setField('title', 'Vocab Flow color fixture.pdf'); |
| 78 | + attachmentItem.attachmentContentType = 'application/pdf'; |
| 79 | + attachmentItem.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_FILE; |
| 80 | + attachmentItem.attachmentPath = 'vocab-flow-color-fixture.pdf'; |
| 81 | + const attachmentID = await saveItem(attachmentItem); |
| 82 | + parentItem.getAttachments = () => [attachmentID]; |
| 83 | + |
| 84 | + for (let index = 0; index < VOCAB_FLOW_COLOR_FIXTURE.colors.length; index += 1) { |
| 85 | + const fixture = VOCAB_FLOW_COLOR_FIXTURE.colors[index]; |
| 86 | + const annotation = await Zotero.Annotations.saveFromJSON(attachmentItem, { |
| 87 | + key: Zotero.DataObjectUtilities.generateKey(), |
| 88 | + type: 'underline', |
| 89 | + text: fixture.marker, |
| 90 | + color: fixture.color, |
| 91 | + pageLabel: '1', |
| 92 | + sortIndex: `00001|${String(index + 1).padStart(6, '0')}|00001`, |
| 93 | + position: { |
| 94 | + pageIndex: 0, |
| 95 | + rects: [[72, 72 + index * 18, 240, 84 + index * 18]] |
| 96 | + }, |
| 97 | + tags: [] |
| 98 | + }); |
| 99 | + createdItemIDs.push(annotation.id); |
| 100 | + } |
| 101 | + |
| 102 | + return parentItem; |
| 103 | + } |
| 104 | + |
| 105 | + async function getGeneratedNotes() { |
| 106 | + const noteIDs = new Set(parentItem.getNotes?.() ?? []); |
| 107 | + const dbNoteIDs = await Zotero.DB.columnQueryAsync( |
| 108 | + 'SELECT itemID FROM itemNotes WHERE parentItemID=? AND note LIKE ?', |
| 109 | + [parentItem.id, '%data-vocab-flow-candidates="review"%'] |
| 110 | + ); |
| 111 | + for (const id of dbNoteIDs ?? []) noteIDs.add(id); |
| 112 | + return [...noteIDs] |
| 113 | + .map((id) => Zotero.Items.get(id)) |
| 114 | + .filter((note) => note && !note.deleted && String(note.getNote?.() ?? '').includes('data-vocab-flow-candidates="review"')); |
| 115 | + } |
| 116 | + |
| 117 | + function extractTerms(note) { |
| 118 | + const html = String(note.getNote?.() ?? ''); |
| 119 | + return [...html.matchAll(/data-vocab-flow-candidate="([^"]+)"/g)].map((match) => match[1]); |
| 120 | + } |
| 121 | + |
| 122 | + async function cleanup() { |
| 123 | + for (const note of await getGeneratedNotes()) { |
| 124 | + generatedNoteIDs.push(note.id); |
| 125 | + await note.eraseTx(); |
| 126 | + } |
| 127 | + for (const id of createdItemIDs.slice().reverse()) { |
| 128 | + const item = Zotero.Items.get(id); |
| 129 | + if (item && !item.deleted) await item.eraseTx(); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + try { |
| 134 | + const item = await createFixtureItem(); |
| 135 | + const runtime = await loadRuntimeExports(); |
| 136 | + const toasts = []; |
| 137 | + const manager = new runtime.VocabFlowMenuManager({ |
| 138 | + extractForItem: (selectedItem, options) => runtime.extractForItem(selectedItem, undefined, { notify: false, ...options }), |
| 139 | + toast: (message) => toasts.push(message), |
| 140 | + showGeneratedNote: () => {} |
| 141 | + }); |
| 142 | + const results = []; |
| 143 | + |
| 144 | + for (const fixture of VOCAB_FLOW_COLOR_FIXTURE.colors) { |
| 145 | + const preflightTexts = runtime.readUnderlineTexts(item, { scope: 'color', color: fixture.color }); |
| 146 | + const preflightCandidates = runtime.generateVocabCandidates(preflightTexts).map((candidate) => candidate.label); |
| 147 | + await manager.runColorForTesting(fixture.color, { items: [item] }); |
| 148 | + const notes = await getGeneratedNotes(); |
| 149 | + assert( |
| 150 | + notes.length === 1, |
| 151 | + `${fixture.name} should leave exactly one generated candidate note, found ${notes.length}; texts=${JSON.stringify(preflightTexts)} candidates=${JSON.stringify(preflightCandidates)} toasts=${JSON.stringify(toasts)}` |
| 152 | + ); |
| 153 | + const terms = extractTerms(notes[0]); |
| 154 | + const joined = terms.join(' | ').toLowerCase(); |
| 155 | + assert(joined.includes(fixture.marker.split(' ')[1]), `${fixture.name} candidates did not include marker term from ${fixture.marker}`); |
| 156 | + |
| 157 | + for (const other of VOCAB_FLOW_COLOR_FIXTURE.colors) { |
| 158 | + if (other === fixture) continue; |
| 159 | + assert(!joined.includes(other.marker.split(' ')[1]), `${fixture.name} candidates leaked marker from ${other.name}`); |
| 160 | + } |
| 161 | + |
| 162 | + results.push({ |
| 163 | + color: fixture.name, |
| 164 | + hex: fixture.color, |
| 165 | + noteID: notes[0].id, |
| 166 | + candidates: terms |
| 167 | + }); |
| 168 | + generatedNoteIDs.push(notes[0].id); |
| 169 | + await notes[0].eraseTx(); |
| 170 | + } |
| 171 | + |
| 172 | + await cleanup(); |
| 173 | + return { |
| 174 | + ok: true, |
| 175 | + startedAt, |
| 176 | + parentItemID: parentItem.id, |
| 177 | + attachmentItemID: attachmentItem.id, |
| 178 | + results, |
| 179 | + cleanup: { |
| 180 | + generatedNoteIDs, |
| 181 | + createdItemIDs, |
| 182 | + activeGeneratedNotes: (await getGeneratedNotes()).length |
| 183 | + } |
| 184 | + }; |
| 185 | + } catch (error) { |
| 186 | + await cleanup(); |
| 187 | + return { |
| 188 | + ok: false, |
| 189 | + startedAt, |
| 190 | + errorMessage: String(error?.message ?? error), |
| 191 | + error: String(error && error.stack ? error.stack : error), |
| 192 | + cleanup: { |
| 193 | + generatedNoteIDs, |
| 194 | + createdItemIDs, |
| 195 | + activeGeneratedNotes: (await getGeneratedNotes()).length |
| 196 | + } |
| 197 | + }; |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +return runVocabFlowColorFixture(); |
0 commit comments