From 76c66cff741f39d9e0d0c471a2ba9a1b33cb347f Mon Sep 17 00:00:00 2001 From: Matthew Reichhoff Date: Sun, 11 Jan 2026 16:09:59 -0500 Subject: [PATCH 1/4] Add anki connect This is very v0...more to come --- public/css/hanzi-graph.css | 62 +++ public/index.html | 195 ++++++--- public/js/modules/anki.js | 386 ++++++++++++++++++ public/js/modules/data-layer.js | 139 ++++++- .../js/modules/search-suggestions-worker.js | 12 + public/js/modules/search.js | 20 + public/js/modules/settings.js | 314 +++++++++++++- 7 files changed, 1061 insertions(+), 67 deletions(-) create mode 100644 public/js/modules/anki.js diff --git a/public/css/hanzi-graph.css b/public/css/hanzi-graph.css index 7d30cf71..b4a55cf4 100644 --- a/public/css/hanzi-graph.css +++ b/public/css/hanzi-graph.css @@ -1659,6 +1659,42 @@ using settings names for now padding-bottom: 40px; } +.settings-nav { + display: flex; + gap: 0; + margin-bottom: 20px; + border-bottom: var(--border); +} + +.settings-tab { + padding: 12px 24px; + background: none; + border: none; + border-bottom: 3px solid transparent; + font-size: 16px; + font-weight: 500; + color: var(--secondary-font-color); + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.settings-tab:hover { + color: var(--primary-font-color); +} + +.settings-tab.active { + color: var(--link-color); + border-bottom-color: var(--link-color); +} + +.settings-panel { + display: none; +} + +.settings-panel.active { + display: block; +} + .settings-section { margin-bottom: 30px; padding: 20px; @@ -1808,6 +1844,32 @@ using settings names for now color: #f0a500; } +.settings-actions { + display: flex; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; +} + +.settings-actions button { + padding: 12px 24px; + font-size: 16px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.2s; +} + +.settings-actions button:hover { + opacity: 0.9; +} + +.settings-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* TODO(refactor): signin form stuff should be cleaned */ main.auth-form { width: 275px; diff --git a/public/index.html b/public/index.html index 358084b2..88628f0f 100644 --- a/public/index.html +++ b/public/index.html @@ -368,63 +368,156 @@

What is the flow diagram?

+ +
+
+

Anki Connect Settings

+

+ Sync your flashcards with Anki using the + Anki-Connect plugin. +

+ +
+ +
+ +
+ + +

The URL where Anki-Connect is running (default port is 8765).

+
+ +
+ + +
+ +
+ + + +

The Anki deck where cards will be synced. Will be created if it doesn't + exist.

+
+ +
+ + +

The note type (model) to use for cards. A custom type will be created + if needed.

+
+ +
+ + +

Only required if you've configured authentication in Anki-Connect.

+
+ + + +
+ + +
+

+
+ +
+

Instructions

+
    +
  1. Install Anki if you haven't already. +
  2. +
  3. Install the Anki-Connect + plugin (Tools → Add-ons → Get Add-ons → Enter code: 2055492159).
  4. +
  5. Restart Anki after installing the plugin.
  6. +
  7. Keep Anki running in the background.
  8. +
  9. Click "Test Connection" to verify connectivity.
  10. +
  11. Enable Anki Sync to automatically sync new cards.
  12. +
+

+ Note: Anki must be running for sync to work. On macOS, you may need to + disable + App Nap. +

+
+
+

Cards due:

diff --git a/public/js/modules/study-mode.js b/public/js/modules/study-mode.js index 0d59ed38..7a030e32 100644 --- a/public/js/modules/study-mode.js +++ b/public/js/modules/study-mode.js @@ -1,6 +1,7 @@ import { makeSentenceNavigable, addTextToSpeech } from "./explore.js"; import { dataTypes, registerCallback, saveStudyList, getStudyList, findOtherCards, removeFromStudyList, recordEvent, studyResult, updateCard, cardTypes } from "./data-layer.js"; import { registerStateChangeCallback, stateKeys } from "./ui-orchestrator.js"; +import * as anki from "./anki.js"; const studyContainer = document.getElementById('study-container'); @@ -34,6 +35,7 @@ const clozePlaceholder = clozePlaceholderCharacter + clozePlaceholderCharacter + const explanationContainer = document.getElementById('study-explain-container'); const explanationHideButton = document.getElementById('hide-study-explanation'); +const ankiNudgeContainer = document.getElementById('anki-nudge-container'); let currentKey = null; let studyModeActive = false; @@ -159,6 +161,14 @@ let setupStudyMode = function () { let currentCard = null; cardAnswerContainer.style.display = 'none'; showAnswerButton.innerText = "Show Answer"; + + // Show Anki nudge if enabled and not dismissed + if (anki.isAnkiEnabled()) { + ankiNudgeContainer.removeAttribute('style'); + } else { + ankiNudgeContainer.style.display = 'none'; + } + let counter = 0; for (const [key, value] of Object.entries(studyList)) { if (value.due <= Date.now()) { From 924688f42ef056ca4009107fb882b81099f53410 Mon Sep 17 00:00:00 2001 From: Matthew Reichhoff Date: Sun, 11 Jan 2026 16:55:04 -0500 Subject: [PATCH 3/4] Fix minor style issues with integration settings view --- public/css/hanzi-graph.css | 4 +++- public/js/modules/settings.js | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/css/hanzi-graph.css b/public/css/hanzi-graph.css index b4a55cf4..71acbbb9 100644 --- a/public/css/hanzi-graph.css +++ b/public/css/hanzi-graph.css @@ -46,7 +46,7 @@ --legend-height: 30px; --graph-height: calc(100% - var(--legend-height) - 4px); --section-container-margin: 0 16px; - --calendar-day-color: #eee; + --calendar-day-color: #3333; --bar-chart-separator-color: #121212; --bar-chart-height: 400px; --bar-chart-width: 600px; @@ -109,6 +109,8 @@ body { /* in general, disallow overscroll gestures from causing refresh */ /* the idea is to feel more like a native app */ overscroll-behavior: contain; + background-attachment: fixed; + background-repeat: no-repeat; } body.allow-overscroll { diff --git a/public/js/modules/settings.js b/public/js/modules/settings.js index 3389aae6..75a90924 100644 --- a/public/js/modules/settings.js +++ b/public/js/modules/settings.js @@ -419,6 +419,7 @@ function initialize() { ankiSyncStatus.className = 'connection-status status-error'; } syncToAnkiButton.disabled = false; + ankiSyncStatus.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); // Listen for import completion @@ -434,6 +435,7 @@ function initialize() { if (importFromAnkiButton) { importFromAnkiButton.disabled = false; } + ankiSyncStatus.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); } From ca2fb808755b20d32fae30365c564679c0019058 Mon Sep 17 00:00:00 2001 From: Matthew Reichhoff Date: Sun, 11 Jan 2026 19:31:58 -0500 Subject: [PATCH 4/4] Remove note type (model) picker No need to burden users with this. --- public/index.html | 9 ------ public/js/modules/anki.js | 19 +++-------- public/js/modules/data-layer.js | 2 +- public/js/modules/settings.js | 56 +++------------------------------ 4 files changed, 9 insertions(+), 77 deletions(-) diff --git a/public/index.html b/public/index.html index cb29ca95..0881cdfd 100644 --- a/public/index.html +++ b/public/index.html @@ -475,15 +475,6 @@

Anki Connect Settings

exist.

-
- - -

The note type (model) to use for cards. A custom type will be created - if needed.

-
-
{ + anki.removeCard(cardToRemove).catch(err => { console.error('Failed to remove card from Anki:', err); }); } diff --git a/public/js/modules/settings.js b/public/js/modules/settings.js index 75a90924..8193480c 100644 --- a/public/js/modules/settings.js +++ b/public/js/modules/settings.js @@ -22,7 +22,6 @@ const localAiStatus = document.getElementById('local-ai-status'); const ankiEnabledCheckbox = document.getElementById('anki-enabled'); const ankiEndpointInput = document.getElementById('anki-endpoint'); const ankiDeckSelect = document.getElementById('anki-deck'); -const ankiModelSelect = document.getElementById('anki-model'); const ankiApiKeyInput = document.getElementById('anki-api-key'); const testAnkiConnectionButton = document.getElementById('test-anki-connection'); const refreshDecksButton = document.getElementById('refresh-decks-button'); @@ -33,8 +32,6 @@ const syncToAnkiButton = document.getElementById('sync-to-anki-button'); const importFromAnkiButton = document.getElementById('import-from-anki-button'); const ankiSyncStatus = document.getElementById('anki-sync-status'); -// ==================== Tab Navigation ==================== - function switchTab(tabName) { settingsTabs.forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === tabName); @@ -44,8 +41,6 @@ function switchTab(tabName) { }); } -// ==================== Local AI Functions ==================== - function updateLocalAiStatus() { const settings = localAi.getSettings(); @@ -163,8 +158,6 @@ function loadLocalAiSettings() { updateLocalAiStatus(); } -// ==================== Anki Functions ==================== - function updateAnkiStatus() { const settings = anki.getSettings(); @@ -208,31 +201,6 @@ function populateDeckSelect(decks, selectedDeck) { }); } -function populateAnkiModelSelect(models, selectedModel) { - ankiModelSelect.innerHTML = ''; - - // Always include the default HanziGraph Basic option - const defaultOption = document.createElement('option'); - defaultOption.value = 'HanziGraph Basic'; - defaultOption.textContent = 'HanziGraph Basic'; - if (selectedModel === 'HanziGraph Basic' || !selectedModel) { - defaultOption.selected = true; - } - ankiModelSelect.appendChild(defaultOption); - - models.forEach(model => { - if (model !== 'HanziGraph Basic') { - const option = document.createElement('option'); - option.value = model; - option.textContent = model; - if (model === selectedModel) { - option.selected = true; - } - ankiModelSelect.appendChild(option); - } - }); -} - async function handleTestAnkiConnection() { ankiConnectionStatus.textContent = 'Testing...'; ankiConnectionStatus.className = 'connection-status status-testing'; @@ -246,15 +214,12 @@ async function handleTestAnkiConnection() { ankiConnectionStatus.textContent = `✓ Connected (v${result.version})`; ankiConnectionStatus.className = 'connection-status status-success'; - // Fetch and populate decks and models + // Fetch and populate decks try { const decks = await anki.getDecks(); populateDeckSelect(decks, anki.getSettings().deckName); - - const models = await anki.getModels(); - populateAnkiModelSelect(models, anki.getSettings().modelName); } catch (error) { - console.error('Failed to fetch decks/models:', error); + console.error('Failed to fetch decks:', error); } } else { ankiConnectionStatus.textContent = `✗ ${result.error}`; @@ -270,9 +235,6 @@ async function handleRefreshDecks() { const decks = await anki.getDecks(); populateDeckSelect(decks, anki.getSettings().deckName); - const models = await anki.getModels(); - populateAnkiModelSelect(models, anki.getSettings().modelName); - ankiConnectionStatus.textContent = '✓ Refreshed'; ankiConnectionStatus.className = 'connection-status status-success'; } catch (error) { @@ -286,10 +248,6 @@ function handleAnkiDeckChange() { updateAnkiStatus(); } -function handleAnkiModelChange() { - anki.saveSettings({ modelName: ankiModelSelect.value }); -} - function handleAnkiEnabledChange() { const enabled = ankiEnabledCheckbox.checked; anki.saveSettings({ enabled }); @@ -355,16 +313,11 @@ function loadAnkiSettings() { ankiEnabledCheckbox.checked = settings.enabled; ankiEndpointInput.value = settings.endpoint; ankiApiKeyInput.value = settings.apiKey || ''; - - // Set the select values ankiDeckSelect.value = settings.deckName || 'HanziGraph'; - ankiModelSelect.value = settings.modelName || 'HanziGraph Basic'; updateAnkiStatus(); } -// ==================== Initialize ==================== - function initialize() { // Load initial settings loadLocalAiSettings(); @@ -391,7 +344,6 @@ function initialize() { ankiEnabledCheckbox.addEventListener('change', handleAnkiEnabledChange); ankiEndpointInput.addEventListener('change', handleAnkiEndpointChange); ankiDeckSelect.addEventListener('change', handleAnkiDeckChange); - ankiModelSelect.addEventListener('change', handleAnkiModelChange); ankiApiKeyInput.addEventListener('change', handleAnkiApiKeyChange); testAnkiConnectionButton.addEventListener('click', handleTestAnkiConnection); refreshDecksButton.addEventListener('click', handleRefreshDecks); @@ -407,7 +359,7 @@ function initialize() { loadAnkiSettings(); }); - // Listen for sync completion + // Listen for sync completion and notify user of status document.addEventListener('anki-sync-complete', (event) => { const result = event.detail; if (result.success) { @@ -422,7 +374,7 @@ function initialize() { ankiSyncStatus.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); - // Listen for import completion + // Listen for import completion and notify user of status document.addEventListener('anki-import-complete', (event) => { const result = event.detail; if (result.success) {