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?
-
- Local AI Settings
-
- Connect to a local AI server (like LMStudio)
- to use AI features without signing in. The server must be OpenAI API compatible.
-
+
-
-
-
+
+
+ Local AI Settings
+
+ Connect to a local AI server (like LMStudio)
+ to use AI features without signing in. The server must be OpenAI API compatible.
+
-
-
-
-
The base URL of your local AI server's OpenAI-compatible API.
-
+
+
+
-
-
-
-
+
+
+
+
The base URL of your local AI server's OpenAI-compatible API.
+
-
-
-
-
-
Select the model to use for AI features. Some features (like image
- analysis) require vision-capable models.
-
+
+
+
+
-
-
+
+
+
+
+
Select the model to use for AI features. Some features (like image
+ analysis) require vision-capable models.
+
-
- Instructions
-
- - Download and install LMStudio (or another
- OpenAI-compatible server).
- - Download a model in LMStudio (e.g., Llama, Qwen, or a vision model for image analysis).
- - Start the local server in LMStudio (Developer tab → Start Server).
- - Enter the server endpoint above (default:
http://localhost:1234/v1).
- - Click "Test Connection" to verify and load available models.
- - Select a model and enable Local AI.
-
-
- Note: For best results with Chinese, use a model with good multilingual support.
- For image analysis, you'll need a vision-capable model.
-
-
+
+
+
+
+ Instructions
+
+ - Download and install LMStudio (or another
+ OpenAI-compatible server).
+ - Download a model in LMStudio (e.g., Llama, Qwen, or a vision model for image analysis).
+ - Start the local server in LMStudio (Developer tab → Start Server).
+ - Enter the server endpoint above (default:
http://localhost:1234/v1).
+ - Click "Test Connection" to verify and load available models.
+ - Select a model and enable Local AI.
+
+
+ Note: For best results with Chinese, use a model with good multilingual
+ support.
+ For image analysis, you'll need a vision-capable model.
+
+
+
+
+
+
+ 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
+
+ - Install Anki if you haven't already.
+
+ - Install the Anki-Connect
+ plugin (Tools → Add-ons → Get Add-ons → Enter code: 2055492159).
+ - Restart Anki after installing the plugin.
+ - Keep Anki running in the background.
+ - Click "Test Connection" to verify connectivity.
+ - Enable Anki Sync to automatically sync new cards.
+
+
+ Note: Anki must be running for sync to work. On macOS, you may need to
+ disable
+ App Nap.
+
+
+
+
+
Anki sync enabled.
+
Your flash cards are synced to Anki, so you may intend to study there.
+
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) {