// ==UserScript==
// @name TTSU<->Kizuna
// @version 1.0
// @description Stream visible text from TTSU to Kizuna using IntersectionObserver
// @match https://reader.ttsu.app/*
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const SYNC_KEY = 'kizuna_bridge_universal';
// --- HELPER: CLEAN TEXT ---
function getCleanText(el) {
if (!el) return "";
const clone = el.cloneNode(true);
// Remove rt tags (furigana) but keep the base text
clone.querySelectorAll('rt').forEach(rt => rt.remove());
// Remove rp tags (fallback parenthesis)
clone.querySelectorAll('rp').forEach(rp => rp.remove());
return clone.textContent.trim();
}
// --- INJECTOR FOR KIZUNA (RECEIVER) ---
function injectKizunaReceiver() {
const script = document.createElement('script');
script.textContent = `
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'TTSU_TO_KIZUNA') {
window.dispatchEvent(new CustomEvent('kizuna:add-line', {
detail: { text: e.data.text }
}));
}
});
`;
(document.head || document.documentElement).appendChild(script);
}
// --- TTSU READER LOGIC (SENDER) ---
if (window.location.hostname.includes('reader.ttsu.app')) {
// A Set to remember what we have already sent to Kizuna so we don't duplicate
const sentHistory = new Set();
let transmissionBuffer = [];
// A Set to keep track of what is currently on screen according to the browser
const visibleElements = new Set();
// 1. The Observer: The browser tells us what is on screen
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleElements.add(entry.target);
} else {
visibleElements.delete(entry.target);
}
});
}, {
root: null, // viewport
rootMargin: '0px',
threshold: 0.5 // Element must be 50% visible to count (prevents partial lines at edges)
});
// 2. The Scanner: Finds new paragraphs and tells the Observer to watch them
setInterval(() => {
// Target the specific paragraphs based on your structure
const paragraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
paragraphs.forEach(p => {
// If we haven't started watching this specific <p> tag yet, watch it
if (!p.getAttribute('data-bridge-observed')) {
p.setAttribute('data-bridge-observed', 'true');
observer.observe(p);
}
});
}, 1000); // Check for new chapters/content every second
// 3. The Sender: Periodically checks what is visible and sends it
setInterval(() => {
// We select again to ensure we process in DOM ORDER (Top to bottom)
// This ensures text arrives in the correct reading order
const allParagraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
allParagraphs.forEach(p => {
// Logic:
// 1. Is it currently flagged as visible by the Observer?
// 2. Is there valid text?
// 3. Have we sent this specific text string before?
if (visibleElements.has(p)) {
const text = getCleanText(p);
if (text && text.length > 0 && !sentHistory.has(text)) {
sentHistory.add(text);
transmissionBuffer.push(text);
console.log("TTSU Bridge Queued:", text);
}
}
});
// Send batch if data exists
if (transmissionBuffer.length > 0) {
GM_setValue(SYNC_KEY, { lines: [...transmissionBuffer], ts: Date.now() });
transmissionBuffer = [];
}
}, 500); // Check for text to send every 500ms
}
// --- KIZUNA UI LOGIC (RECEIVER) ---
if (window.location.hostname.includes('kizuna-texthooker-ui')) {
injectKizunaReceiver();
GM_addValueChangeListener(SYNC_KEY, (name, old, newVal, remote) => {
if (remote && newVal && newVal.lines) {
newVal.lines.forEach(text => {
window.postMessage({ type: 'TTSU_TO_KIZUNA', text: text }, "*");
});
}
});
}
})();
// ==UserScript==
// @name TTSU<->Kizuna
// @version 1.0
// @description Stream visible text from TTSU to Kizuna using IntersectionObserver
// @match https://reader.ttsu.app/*
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const SYNC_KEY = 'kizuna_bridge_universal';
// --- HELPER: CLEAN TEXT ---
function getCleanText(el) {
if (!el) return "";
const clone = el.cloneNode(true);
// Remove rt tags (furigana) but keep the base text
clone.querySelectorAll('rt').forEach(rt => rt.remove());
// Remove rp tags (fallback parenthesis)
clone.querySelectorAll('rp').forEach(rp => rp.remove());
return clone.textContent.trim();
}
// --- INJECTOR FOR KIZUNA (RECEIVER) ---
function injectKizunaReceiver() {
const script = document.createElement('script');
script.textContent = `
window.addEventListener('message', (e) => {
if (e.data && e.data.type === 'TTSU_TO_KIZUNA') {
window.dispatchEvent(new CustomEvent('kizuna:add-line', {
detail: { text: e.data.text }
}));
}
});
`;
(document.head || document.documentElement).appendChild(script);
}
// --- TTSU READER LOGIC (SENDER) ---
if (window.location.hostname.includes('reader.ttsu.app')) {
// A Set to remember what we have already sent to Kizuna so we don't duplicate
const sentHistory = new Set();
let transmissionBuffer = [];
// A Set to keep track of what is currently on screen according to the browser
const visibleElements = new Set();
// 1. The Observer: The browser tells us what is on screen
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleElements.add(entry.target);
} else {
visibleElements.delete(entry.target);
}
});
}, {
root: null, // viewport
rootMargin: '0px',
threshold: 0.5 // Element must be 50% visible to count (prevents partial lines at edges)
});
// 2. The Scanner: Finds new paragraphs and tells the Observer to watch them
setInterval(() => {
// Target the specific paragraphs based on your structure
const paragraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
paragraphs.forEach(p => {
// If we haven't started watching this specific <p> tag yet, watch it
if (!p.getAttribute('data-bridge-observed')) {
p.setAttribute('data-bridge-observed', 'true');
observer.observe(p);
}
});
}, 1000); // Check for new chapters/content every second
// 3. The Sender: Periodically checks what is visible and sends it
setInterval(() => {
// We select again to ensure we process in DOM ORDER (Top to bottom)
// This ensures text arrives in the correct reading order
const allParagraphs = document.querySelectorAll('.ttu-book-body-wrapper p');
allParagraphs.forEach(p => {
// Logic:
// 1. Is it currently flagged as visible by the Observer?
// 2. Is there valid text?
// 3. Have we sent this specific text string before?
if (visibleElements.has(p)) {
const text = getCleanText(p);
if (text && text.length > 0 && !sentHistory.has(text)) {
sentHistory.add(text);
transmissionBuffer.push(text);
console.log("TTSU Bridge Queued:", text);
}
}
});
// Send batch if data exists
if (transmissionBuffer.length > 0) {
GM_setValue(SYNC_KEY, { lines: [...transmissionBuffer], ts: Date.now() });
transmissionBuffer = [];
}
}, 500); // Check for text to send every 500ms
}
// --- KIZUNA UI LOGIC (RECEIVER) ---
if (window.location.hostname.includes('kizuna-texthooker-ui')) {
injectKizunaReceiver();
GM_addValueChangeListener(SYNC_KEY, (name, old, newVal, remote) => {
if (remote && newVal && newVal.lines) {
newVal.lines.forEach(text => {
window.postMessage({ type: 'TTSU_TO_KIZUNA', text: text }, "*");
});
}
});
}
})();
Automatically add visual novel screenshots to your Anki cards:
// ==UserScript==
// @name Anki Auto Screenshot Capture
// @version 1.0
// @description Automatically capture window screenshots when new Anki cards are created
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @connect localhost
// ==/UserScript==
(function() {
'use strict';
// Configuration
const ANKI_URL = "http://localhost:8765";
const PICTURE_FIELD = "Picture";
const POLL_INTERVAL = 1000; // Check every 1 second
// Crop settings to remove window chrome (title bar, borders)
const CROP_SETTINGS = {
top: 32, // Title bar height
bottom: 8, // Bottom border
left: 8, // Left border
right: 8 // Right border
};
let currentNoteIds = new Set();
let firstRun = true;
let isCapturing = false;
let captureStream = null; // Keep the stream alive
let captureVideo = null; // Keep the video element
let lastWindowDimensions = null; // Track last captured window size
// Start polling for new Anki cards
startPolling();
async function startPolling() {
console.log('Started polling Anki for new cards...');
while (true) {
await checkForNewCards();
await sleep(POLL_INTERVAL);
}
}
async function checkForNewCards() {
try {
const updatedNoteIds = await getNoteIds();
const newCardIds = difference(updatedNoteIds, currentNoteIds);
if (newCardIds.size > 0 && !firstRun) {
console.log('Detected new card in Anki! Capturing screenshot...');
if (!isCapturing) {
isCapturing = true;
await captureAndAddToAnki();
isCapturing = false;
}
}
firstRun = false;
currentNoteIds = updatedNoteIds;
} catch (error) {
console.error('Error checking for new cards:', error);
}
}
async function getNoteIds() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
return new Set(noteIds);
}
function difference(setA, setB) {
const diff = new Set();
for (const elem of setA) {
if (!setB.has(elem)) {
diff.add(elem);
}
}
return diff;
}
async function captureAndAddToAnki() {
try {
// Small delay to ensure card is fully created
await sleep(500);
// Capture the screenshot
const webpDataUrl = await captureWindowScreenshot();
// Get the latest Anki card
const lastNote = await getLastAnkiCard();
if (!lastNote || !lastNote.noteId) {
showNotification('No Anki cards found for today', 'error');
return;
}
// Check if card already has an image
if (lastNote.fields[PICTURE_FIELD] && lastNote.fields[PICTURE_FIELD].value) {
console.log('Latest card already has an image, skipping');
return;
}
// Convert data URL to base64
const base64Data = webpDataUrl.split(',')[1];
// Generate filename
const filename = `screenshot_${Date.now()}.webp`;
// Store the image in Anki
const storedFilename = await storeMediaFile(filename, base64Data);
// Update the card with the image
await updateNoteWithImage(lastNote.noteId, storedFilename);
showNotification('✓ Screenshot added to Anki card!', 'success');
console.log(`Updated Anki card ${lastNote.noteId} with screenshot`);
} catch (error) {
console.error('Error:', error);
showNotification('Failed: ' + error.message, 'error');
}
}
async function initializeCaptureStream() {
try {
// Request screen capture - user will be prompted ONCE to select window
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: {
mediaSource: 'window'
},
audio: false
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
await new Promise((resolve) => {
captureVideo.onloadedmetadata = resolve;
});
console.log('Capture stream initialized and ready');
// Show crop adjustment dialog
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
// Store initial window dimensions
lastWindowDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
console.log('Crop settings updated:', CROP_SETTINGS);
// Handle stream ending (e.g., user stops sharing)
captureStream.getTracks()[0].addEventListener('ended', () => {
console.log('Capture stream ended');
captureStream = null;
captureVideo = null;
lastWindowDimensions = null;
});
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function showCropAdjustmentDialog(video) {
return new Promise(async (resolve) => {
// Load saved crop settings from GM storage
let savedSettings = null;
try {
const saved = await GM.getValue('ankiAutoCropSettings', null);
if (saved) {
savedSettings = JSON.parse(saved);
}
} catch (e) {
console.warn('Failed to load saved crop settings:', e);
}
// Capture current frame for preview
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;
const sourceCtx = sourceCanvas.getContext('2d');
sourceCtx.drawImage(video, 0, 0);
// Create preview canvas for cropped result
const previewCanvas = document.createElement('canvas');
const previewCtx = previewCanvas.getContext('2d');
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 999999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
`;
// Create container
const container = document.createElement('div');
container.style.cssText = `
background: #2b2b2b;
border-radius: 12px;
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 15px;
box-sizing: border-box;
overflow: hidden;
`;
// Title
const title = document.createElement('h2');
title.textContent = 'Adjust Screenshot Crop Area';
title.style.cssText = `
margin: 0;
color: white;
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
flex-shrink: 0;
`;
// Preview container with canvas
const previewContainer = document.createElement('div');
previewContainer.style.cssText = `
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
`;
// Style the preview canvas
previewCanvas.style.cssText = `
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border: 1px solid #555;
`;
// Controls container
const controlsContainer = document.createElement('div');
controlsContainer.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
color: white;
font-family: Arial, sans-serif;
font-size: 14px;
flex-shrink: 0;
`;
// Current crop values - use saved settings if available
let cropValues = savedSettings ? {
left: savedSettings.left,
right: savedSettings.right,
top: savedSettings.top,
bottom: savedSettings.bottom
} : {
left: CROP_SETTINGS.left,
right: CROP_SETTINGS.right,
top: CROP_SETTINGS.top,
bottom: CROP_SETTINGS.bottom
};
// Function to update cropped preview
function updateCroppedPreview() {
// Calculate cropped dimensions
const croppedWidth = sourceCanvas.width - cropValues.left - cropValues.right;
const croppedHeight = sourceCanvas.height - cropValues.top - cropValues.bottom;
// Resize preview canvas to match cropped dimensions
previewCanvas.width = croppedWidth;
previewCanvas.height = croppedHeight;
// Draw the cropped portion from source canvas
previewCtx.drawImage(
sourceCanvas,
cropValues.left, // Source X
cropValues.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
}
// Create slider control
function createSlider(label, initialValue, max, onChange) {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
`;
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const labelElement = document.createElement('label');
labelElement.textContent = label;
labelElement.style.fontWeight = 'bold';
const valueDisplay = document.createElement('span');
valueDisplay.textContent = initialValue + 'px';
valueDisplay.style.cssText = `
color: #4CAF50;
font-family: monospace;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = max;
slider.value = initialValue;
slider.style.cssText = `
width: 100%;
cursor: pointer;
`;
const updateValue = (value) => {
// Clamp value between 0 and max
value = Math.max(0, Math.min(max, value));
slider.value = value;
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
};
slider.addEventListener('input', (e) => {
const value = parseInt(e.target.value);
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
});
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 5px;
justify-content: center;
`;
// Decrement button
const decrementButton = document.createElement('button');
decrementButton.textContent = '−';
decrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
decrementButton.addEventListener('mouseover', () => {
decrementButton.style.background = '#555';
});
decrementButton.addEventListener('mouseout', () => {
decrementButton.style.background = '#444';
});
decrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) - 1);
});
// Increment button
const incrementButton = document.createElement('button');
incrementButton.textContent = '+';
incrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
incrementButton.addEventListener('mouseover', () => {
incrementButton.style.background = '#555';
});
incrementButton.addEventListener('mouseout', () => {
incrementButton.style.background = '#444';
});
incrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) + 1);
});
buttonContainer.appendChild(decrementButton);
buttonContainer.appendChild(incrementButton);
labelContainer.appendChild(labelElement);
labelContainer.appendChild(valueDisplay);
sliderContainer.appendChild(labelContainer);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(buttonContainer);
return sliderContainer;
}
// Create sliders
const leftSlider = createSlider('Left', cropValues.left, Math.floor(video.videoWidth / 2), (value) => {
cropValues.left = value;
});
const rightSlider = createSlider('Right', cropValues.right, Math.floor(video.videoWidth / 2), (value) => {
cropValues.right = value;
});
const topSlider = createSlider('Top', cropValues.top, Math.floor(video.videoHeight / 2), (value) => {
cropValues.top = value;
});
const bottomSlider = createSlider('Bottom', cropValues.bottom, Math.floor(video.videoHeight / 2), (value) => {
cropValues.bottom = value;
});
// Add sliders to controls
controlsContainer.appendChild(leftSlider);
controlsContainer.appendChild(rightSlider);
controlsContainer.appendChild(topSlider);
controlsContainer.appendChild(bottomSlider);
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
justify-content: center;
flex-shrink: 0;
`;
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.textContent = 'Confirm Crop Settings';
confirmButton.style.cssText = `
padding: 12px 24px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
`;
confirmButton.addEventListener('mouseover', () => {
confirmButton.style.background = '#45a049';
});
confirmButton.addEventListener('mouseout', () => {
confirmButton.style.background = '#4CAF50';
});
confirmButton.addEventListener('click', async () => {
// Save crop settings to GM storage
try {
await GM.setValue('ankiAutoCropSettings', JSON.stringify(cropValues));
console.log('Crop settings saved to GM storage:', cropValues);
} catch (e) {
console.warn('Failed to save crop settings to GM storage:', e);
}
overlay.remove();
resolve(cropValues);
});
// Reset button
const resetButton = document.createElement('button');
resetButton.textContent = 'Reset to Default';
resetButton.style.cssText = `
padding: 12px 24px;
background: #666;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-family: Arial, sans-serif;
`;
resetButton.addEventListener('mouseover', () => {
resetButton.style.background = '#555';
});
resetButton.addEventListener('mouseout', () => {
resetButton.style.background = '#666';
});
resetButton.addEventListener('click', () => {
cropValues = { left: 32, right: 8, top: 32, bottom: 8 };
// Update all sliders
leftSlider.querySelector('input').value = cropValues.left;
leftSlider.querySelector('span').textContent = cropValues.left + 'px';
rightSlider.querySelector('input').value = cropValues.right;
rightSlider.querySelector('span').textContent = cropValues.right + 'px';
topSlider.querySelector('input').value = cropValues.top;
topSlider.querySelector('span').textContent = cropValues.top + 'px';
bottomSlider.querySelector('input').value = cropValues.bottom;
bottomSlider.querySelector('span').textContent = cropValues.bottom + 'px';
updateCroppedPreview();
});
// Assemble the dialog
previewContainer.appendChild(previewCanvas);
buttonContainer.appendChild(confirmButton);
buttonContainer.appendChild(resetButton);
container.appendChild(title);
container.appendChild(previewContainer);
container.appendChild(controlsContainer);
container.appendChild(buttonContainer);
overlay.appendChild(container);
document.body.appendChild(overlay);
// Initial preview update
updateCroppedPreview();
});
}
async function captureWindowScreenshot() {
try {
// Initialize stream on first capture
if (!captureStream || !captureVideo) {
await initializeCaptureStream();
}
// Check if window dimensions have changed
const currentDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
if (lastWindowDimensions &&
(lastWindowDimensions.width !== currentDimensions.width ||
lastWindowDimensions.height !== currentDimensions.height)) {
console.log('Window size changed, showing crop adjustment dialog');
console.log('Previous:', lastWindowDimensions, 'Current:', currentDimensions);
// Show crop adjustment dialog again with new dimensions
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
console.log('Crop settings updated:', CROP_SETTINGS);
}
// Store current dimensions for next comparison
lastWindowDimensions = currentDimensions;
// Create canvas and capture current frame
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(captureVideo, 0, 0);
// Create a new canvas for the cropped version
const croppedCanvas = document.createElement('canvas');
const croppedWidth = canvas.width - CROP_SETTINGS.left - CROP_SETTINGS.right;
const croppedHeight = canvas.height - CROP_SETTINGS.top - CROP_SETTINGS.bottom;
croppedCanvas.width = croppedWidth;
croppedCanvas.height = croppedHeight;
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the cropped portion (removes window chrome)
croppedCtx.drawImage(
canvas,
CROP_SETTINGS.left, // Source X
CROP_SETTINGS.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
// Convert to WebP with quality setting (0.9 = 90% quality)
const webpDataUrl = croppedCanvas.toDataURL('image/png', 0.9);
return webpDataUrl;
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function getLastAnkiCard() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
if (!noteIds || noteIds.length === 0) {
return null;
}
const lastNoteId = noteIds[noteIds.length - 1];
const notesInfo = await ankiInvoke('notesInfo', { notes: [lastNoteId] });
return notesInfo[0];
}
async function storeMediaFile(filename, base64Data) {
return await ankiInvoke('storeMediaFile', {
filename: filename,
data: base64Data
});
}
async function updateNoteWithImage(noteId, filename) {
const imageHtml = `<img src="${filename}">`;
await ankiInvoke('updateNoteFields', {
note: {
id: noteId,
fields: {
[PICTURE_FIELD]: imageHtml
}
}
});
}
function ankiInvoke(action, params = {}) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
action: action,
version: 6,
params: params
});
GM_xmlhttpRequest({
method: 'POST',
url: ANKI_URL,
headers: {
'Content-Type': 'application/json'
},
data: payload,
onload: function(response) {
try {
const result = JSON.parse(response.responseText);
if (result.error) {
reject(new Error(result.error));
} else {
resolve(result.result);
}
} catch (e) {
reject(new Error('Failed to parse Anki response'));
}
},
onerror: function(error) {
reject(new Error('Failed to connect to Anki'));
}
});
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
const colors = {
success: '#4CAF50',
error: '#f44336',
warning: '#ff9800',
info: '#2196F3'
};
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
z-index: 10001;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: 500;
max-width: 300px;
animation: slideIn 0.3s ease-out;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
if (!document.querySelector('style[data-anki-notif]')) {
style.setAttribute('data-anki-notif', 'true');
document.head.appendChild(style);
}
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s ease-out';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
console.log('Anki Auto Screenshot Capture loaded. Monitoring for new cards...');
})();
// ==UserScript==
// @name Anki Auto Screenshot Capture
// @version 1.0
// @description Automatically capture window screenshots when new Anki cards are created
// @match https://kizuna-texthooker-ui.app/*
// @grant GM_xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @connect localhost
// ==/UserScript==
(function() {
'use strict';
// Configuration
const ANKI_URL = "http://localhost:8765";
const PICTURE_FIELD = "Picture";
const POLL_INTERVAL = 1000; // Check every 1 second
// Crop settings to remove window chrome (title bar, borders)
const CROP_SETTINGS = {
top: 32, // Title bar height
bottom: 8, // Bottom border
left: 8, // Left border
right: 8 // Right border
};
let currentNoteIds = new Set();
let firstRun = true;
let isCapturing = false;
let captureStream = null; // Keep the stream alive
let captureVideo = null; // Keep the video element
let lastWindowDimensions = null; // Track last captured window size
// Start polling for new Anki cards
startPolling();
async function startPolling() {
console.log('Started polling Anki for new cards...');
while (true) {
await checkForNewCards();
await sleep(POLL_INTERVAL);
}
}
async function checkForNewCards() {
try {
const updatedNoteIds = await getNoteIds();
const newCardIds = difference(updatedNoteIds, currentNoteIds);
if (newCardIds.size > 0 && !firstRun) {
console.log('Detected new card in Anki! Capturing screenshot...');
if (!isCapturing) {
isCapturing = true;
await captureAndAddToAnki();
isCapturing = false;
}
}
firstRun = false;
currentNoteIds = updatedNoteIds;
} catch (error) {
console.error('Error checking for new cards:', error);
}
}
async function getNoteIds() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
return new Set(noteIds);
}
function difference(setA, setB) {
const diff = new Set();
for (const elem of setA) {
if (!setB.has(elem)) {
diff.add(elem);
}
}
return diff;
}
async function captureAndAddToAnki() {
try {
// Small delay to ensure card is fully created
await sleep(500);
// Capture the screenshot
const webpDataUrl = await captureWindowScreenshot();
// Get the latest Anki card
const lastNote = await getLastAnkiCard();
if (!lastNote || !lastNote.noteId) {
showNotification('No Anki cards found for today', 'error');
return;
}
// Check if card already has an image
if (lastNote.fields[PICTURE_FIELD] && lastNote.fields[PICTURE_FIELD].value) {
console.log('Latest card already has an image, skipping');
return;
}
// Convert data URL to base64
const base64Data = webpDataUrl.split(',')[1];
// Generate filename
const filename = `screenshot_${Date.now()}.webp`;
// Store the image in Anki
const storedFilename = await storeMediaFile(filename, base64Data);
// Update the card with the image
await updateNoteWithImage(lastNote.noteId, storedFilename);
showNotification('✓ Screenshot added to Anki card!', 'success');
console.log(`Updated Anki card ${lastNote.noteId} with screenshot`);
} catch (error) {
console.error('Error:', error);
showNotification('Failed: ' + error.message, 'error');
}
}
async function initializeCaptureStream() {
try {
// Request screen capture - user will be prompted ONCE to select window
captureStream = await navigator.mediaDevices.getDisplayMedia({
video: {
mediaSource: 'window'
},
audio: false
});
captureVideo = document.createElement('video');
captureVideo.srcObject = captureStream;
captureVideo.autoplay = true;
await new Promise((resolve) => {
captureVideo.onloadedmetadata = resolve;
});
console.log('Capture stream initialized and ready');
// Show crop adjustment dialog
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
// Store initial window dimensions
lastWindowDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
console.log('Crop settings updated:', CROP_SETTINGS);
// Handle stream ending (e.g., user stops sharing)
captureStream.getTracks()[0].addEventListener('ended', () => {
console.log('Capture stream ended');
captureStream = null;
captureVideo = null;
lastWindowDimensions = null;
});
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function showCropAdjustmentDialog(video) {
return new Promise(async (resolve) => {
// Load saved crop settings from GM storage
let savedSettings = null;
try {
const saved = await GM.getValue('ankiAutoCropSettings', null);
if (saved) {
savedSettings = JSON.parse(saved);
}
} catch (e) {
console.warn('Failed to load saved crop settings:', e);
}
// Capture current frame for preview
const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;
const sourceCtx = sourceCanvas.getContext('2d');
sourceCtx.drawImage(video, 0, 0);
// Create preview canvas for cropped result
const previewCanvas = document.createElement('canvas');
const previewCtx = previewCanvas.getContext('2d');
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 999999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
`;
// Create container
const container = document.createElement('div');
container.style.cssText = `
background: #2b2b2b;
border-radius: 12px;
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 15px;
box-sizing: border-box;
overflow: hidden;
`;
// Title
const title = document.createElement('h2');
title.textContent = 'Adjust Screenshot Crop Area';
title.style.cssText = `
margin: 0;
color: white;
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
flex-shrink: 0;
`;
// Preview container with canvas
const previewContainer = document.createElement('div');
previewContainer.style.cssText = `
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
`;
// Style the preview canvas
previewCanvas.style.cssText = `
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border: 1px solid #555;
`;
// Controls container
const controlsContainer = document.createElement('div');
controlsContainer.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
color: white;
font-family: Arial, sans-serif;
font-size: 14px;
flex-shrink: 0;
`;
// Current crop values - use saved settings if available
let cropValues = savedSettings ? {
left: savedSettings.left,
right: savedSettings.right,
top: savedSettings.top,
bottom: savedSettings.bottom
} : {
left: CROP_SETTINGS.left,
right: CROP_SETTINGS.right,
top: CROP_SETTINGS.top,
bottom: CROP_SETTINGS.bottom
};
// Function to update cropped preview
function updateCroppedPreview() {
// Calculate cropped dimensions
const croppedWidth = sourceCanvas.width - cropValues.left - cropValues.right;
const croppedHeight = sourceCanvas.height - cropValues.top - cropValues.bottom;
// Resize preview canvas to match cropped dimensions
previewCanvas.width = croppedWidth;
previewCanvas.height = croppedHeight;
// Draw the cropped portion from source canvas
previewCtx.drawImage(
sourceCanvas,
cropValues.left, // Source X
cropValues.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
}
// Create slider control
function createSlider(label, initialValue, max, onChange) {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
`;
const labelContainer = document.createElement('div');
labelContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
`;
const labelElement = document.createElement('label');
labelElement.textContent = label;
labelElement.style.fontWeight = 'bold';
const valueDisplay = document.createElement('span');
valueDisplay.textContent = initialValue + 'px';
valueDisplay.style.cssText = `
color: #4CAF50;
font-family: monospace;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = max;
slider.value = initialValue;
slider.style.cssText = `
width: 100%;
cursor: pointer;
`;
const updateValue = (value) => {
// Clamp value between 0 and max
value = Math.max(0, Math.min(max, value));
slider.value = value;
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
};
slider.addEventListener('input', (e) => {
const value = parseInt(e.target.value);
valueDisplay.textContent = value + 'px';
onChange(value);
updateCroppedPreview();
});
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 5px;
justify-content: center;
`;
// Decrement button
const decrementButton = document.createElement('button');
decrementButton.textContent = '−';
decrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
decrementButton.addEventListener('mouseover', () => {
decrementButton.style.background = '#555';
});
decrementButton.addEventListener('mouseout', () => {
decrementButton.style.background = '#444';
});
decrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) - 1);
});
// Increment button
const incrementButton = document.createElement('button');
incrementButton.textContent = '+';
incrementButton.style.cssText = `
padding: 6px 12px;
background: #444;
color: white;
border: none;
border-radius: 4px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
min-width: 40px;
`;
incrementButton.addEventListener('mouseover', () => {
incrementButton.style.background = '#555';
});
incrementButton.addEventListener('mouseout', () => {
incrementButton.style.background = '#444';
});
incrementButton.addEventListener('click', () => {
updateValue(parseInt(slider.value) + 1);
});
buttonContainer.appendChild(decrementButton);
buttonContainer.appendChild(incrementButton);
labelContainer.appendChild(labelElement);
labelContainer.appendChild(valueDisplay);
sliderContainer.appendChild(labelContainer);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(buttonContainer);
return sliderContainer;
}
// Create sliders
const leftSlider = createSlider('Left', cropValues.left, Math.floor(video.videoWidth / 2), (value) => {
cropValues.left = value;
});
const rightSlider = createSlider('Right', cropValues.right, Math.floor(video.videoWidth / 2), (value) => {
cropValues.right = value;
});
const topSlider = createSlider('Top', cropValues.top, Math.floor(video.videoHeight / 2), (value) => {
cropValues.top = value;
});
const bottomSlider = createSlider('Bottom', cropValues.bottom, Math.floor(video.videoHeight / 2), (value) => {
cropValues.bottom = value;
});
// Add sliders to controls
controlsContainer.appendChild(leftSlider);
controlsContainer.appendChild(rightSlider);
controlsContainer.appendChild(topSlider);
controlsContainer.appendChild(bottomSlider);
// Button container
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
gap: 10px;
justify-content: center;
flex-shrink: 0;
`;
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.textContent = 'Confirm Crop Settings';
confirmButton.style.cssText = `
padding: 12px 24px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
font-family: Arial, sans-serif;
`;
confirmButton.addEventListener('mouseover', () => {
confirmButton.style.background = '#45a049';
});
confirmButton.addEventListener('mouseout', () => {
confirmButton.style.background = '#4CAF50';
});
confirmButton.addEventListener('click', async () => {
// Save crop settings to GM storage
try {
await GM.setValue('ankiAutoCropSettings', JSON.stringify(cropValues));
console.log('Crop settings saved to GM storage:', cropValues);
} catch (e) {
console.warn('Failed to save crop settings to GM storage:', e);
}
overlay.remove();
resolve(cropValues);
});
// Reset button
const resetButton = document.createElement('button');
resetButton.textContent = 'Reset to Default';
resetButton.style.cssText = `
padding: 12px 24px;
background: #666;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
font-family: Arial, sans-serif;
`;
resetButton.addEventListener('mouseover', () => {
resetButton.style.background = '#555';
});
resetButton.addEventListener('mouseout', () => {
resetButton.style.background = '#666';
});
resetButton.addEventListener('click', () => {
cropValues = { left: 32, right: 8, top: 32, bottom: 8 };
// Update all sliders
leftSlider.querySelector('input').value = cropValues.left;
leftSlider.querySelector('span').textContent = cropValues.left + 'px';
rightSlider.querySelector('input').value = cropValues.right;
rightSlider.querySelector('span').textContent = cropValues.right + 'px';
topSlider.querySelector('input').value = cropValues.top;
topSlider.querySelector('span').textContent = cropValues.top + 'px';
bottomSlider.querySelector('input').value = cropValues.bottom;
bottomSlider.querySelector('span').textContent = cropValues.bottom + 'px';
updateCroppedPreview();
});
// Assemble the dialog
previewContainer.appendChild(previewCanvas);
buttonContainer.appendChild(confirmButton);
buttonContainer.appendChild(resetButton);
container.appendChild(title);
container.appendChild(previewContainer);
container.appendChild(controlsContainer);
container.appendChild(buttonContainer);
overlay.appendChild(container);
document.body.appendChild(overlay);
// Initial preview update
updateCroppedPreview();
});
}
async function captureWindowScreenshot() {
try {
// Initialize stream on first capture
if (!captureStream || !captureVideo) {
await initializeCaptureStream();
}
// Check if window dimensions have changed
const currentDimensions = {
width: captureVideo.videoWidth,
height: captureVideo.videoHeight
};
if (lastWindowDimensions &&
(lastWindowDimensions.width !== currentDimensions.width ||
lastWindowDimensions.height !== currentDimensions.height)) {
console.log('Window size changed, showing crop adjustment dialog');
console.log('Previous:', lastWindowDimensions, 'Current:', currentDimensions);
// Show crop adjustment dialog again with new dimensions
const userCropSettings = await showCropAdjustmentDialog(captureVideo);
// Update crop settings with user values
CROP_SETTINGS.left = userCropSettings.left;
CROP_SETTINGS.right = userCropSettings.right;
CROP_SETTINGS.top = userCropSettings.top;
CROP_SETTINGS.bottom = userCropSettings.bottom;
console.log('Crop settings updated:', CROP_SETTINGS);
}
// Store current dimensions for next comparison
lastWindowDimensions = currentDimensions;
// Create canvas and capture current frame
const canvas = document.createElement('canvas');
canvas.width = captureVideo.videoWidth;
canvas.height = captureVideo.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(captureVideo, 0, 0);
// Create a new canvas for the cropped version
const croppedCanvas = document.createElement('canvas');
const croppedWidth = canvas.width - CROP_SETTINGS.left - CROP_SETTINGS.right;
const croppedHeight = canvas.height - CROP_SETTINGS.top - CROP_SETTINGS.bottom;
croppedCanvas.width = croppedWidth;
croppedCanvas.height = croppedHeight;
const croppedCtx = croppedCanvas.getContext('2d');
// Draw the cropped portion (removes window chrome)
croppedCtx.drawImage(
canvas,
CROP_SETTINGS.left, // Source X
CROP_SETTINGS.top, // Source Y
croppedWidth, // Source width
croppedHeight, // Source height
0, // Dest X
0, // Dest Y
croppedWidth, // Dest width
croppedHeight // Dest height
);
// Convert to WebP with quality setting (0.9 = 90% quality)
const webpDataUrl = croppedCanvas.toDataURL('image/png', 0.9);
return webpDataUrl;
} catch (error) {
throw new Error('Screenshot capture cancelled or failed: ' + error.message);
}
}
async function getLastAnkiCard() {
const noteIds = await ankiInvoke('findNotes', { query: 'added:1' });
if (!noteIds || noteIds.length === 0) {
return null;
}
const lastNoteId = noteIds[noteIds.length - 1];
const notesInfo = await ankiInvoke('notesInfo', { notes: [lastNoteId] });
return notesInfo[0];
}
async function storeMediaFile(filename, base64Data) {
return await ankiInvoke('storeMediaFile', {
filename: filename,
data: base64Data
});
}
async function updateNoteWithImage(noteId, filename) {
const imageHtml = `<img src="${filename}">`;
await ankiInvoke('updateNoteFields', {
note: {
id: noteId,
fields: {
[PICTURE_FIELD]: imageHtml
}
}
});
}
function ankiInvoke(action, params = {}) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify({
action: action,
version: 6,
params: params
});
GM_xmlhttpRequest({
method: 'POST',
url: ANKI_URL,
headers: {
'Content-Type': 'application/json'
},
data: payload,
onload: function(response) {
try {
const result = JSON.parse(response.responseText);
if (result.error) {
reject(new Error(result.error));
} else {
resolve(result.result);
}
} catch (e) {
reject(new Error('Failed to parse Anki response'));
}
},
onerror: function(error) {
reject(new Error('Failed to connect to Anki'));
}
});
});
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
const colors = {
success: '#4CAF50',
error: '#f44336',
warning: '#ff9800',
info: '#2196F3'
};
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${colors[type]};
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
z-index: 10001;
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: 500;
max-width: 300px;
animation: slideIn 0.3s ease-out;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
if (!document.querySelector('style[data-anki-notif]')) {
style.setAttribute('data-anki-notif', 'true');
document.head.appendChild(style);
}
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s ease-out';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
console.log('Anki Auto Screenshot Capture loaded. Monitoring for new cards...');
})();