;
+ showAnswer: boolean;
+ answerToggled: boolean;
+}) {
+ if (note.content.type === NoteType.Cloze) {
+ return getAdapter(note).displayNote(
+ note,
+ showAnswer ? "strict" : answerToggled ? "optional" : "none"
+ );
+ }
+
+ const preview = getNotePreview(note);
+
+ return (
+
+
{preview.front}
+ {preview.back && (
+
{preview.back}
+ )}
+
+ );
+}
diff --git a/src/app/cards/CardsView.css b/src/app/cards/CardsView.css
new file mode 100644
index 00000000..b94300e1
--- /dev/null
+++ b/src/app/cards/CardsView.css
@@ -0,0 +1,78 @@
+.cards-view {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ width: 100%;
+ max-width: 100%;
+}
+
+.cards-view__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-xs);
+ flex-wrap: nowrap;
+}
+
+.cards-view__search {
+ flex: 1;
+ max-width: 28rem;
+ min-width: 12rem;
+}
+
+.cards-view__search svg {
+ width: var(--icon-size-sm);
+ height: var(--icon-size-sm);
+}
+
+.cards-view__limit-notice {
+ color: var(--theme-neutral-500);
+ text-align: center;
+ padding: var(--spacing-sm);
+}
+
+@media (max-width: 575.98px) {
+ .cards-view__toolbar {
+ align-items: stretch;
+ flex-wrap: wrap;
+ }
+
+ .cards-view__search {
+ max-width: none;
+ min-width: 100%;
+ }
+}
+
+.cards-view__list > * {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 200px;
+}
+
+.cards-view__card-wrapper {
+ transition: opacity 150ms ease;
+ margin-bottom: var(--spacing-sm);
+}
+
+.cards-view__card-wrapper--dragging {
+ opacity: 0.6;
+}
+
+.cards-view__preview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-md);
+}
+
+.cards-view__preview-front {
+ font-family: var(--font-serif);
+ font-size: var(--font-size-xl);
+ font-weight: 600;
+ margin: 0;
+}
+
+.cards-view__preview-back {
+ color: var(--theme-neutral-600);
+ font-size: var(--font-size-md);
+ line-height: var(--line-height-md);
+}
diff --git a/src/app/notebook/NotebookView.tsx b/src/app/cards/CardsView.tsx
similarity index 76%
rename from src/app/notebook/NotebookView.tsx
rename to src/app/cards/CardsView.tsx
index 88f20f54..e7d554c4 100644
--- a/src/app/notebook/NotebookView.tsx
+++ b/src/app/cards/CardsView.tsx
@@ -1,6 +1,7 @@
import { Kbd } from "@/components/ui/Kbd";
import { Menu, MenuItem } from "@/components/ui/Menu";
import { Select, SelectOption } from "@/components/ui/Select";
+import { TextInput } from "@/components/ui/TextInput";
import { Tooltip } from "@/components/ui/Tooltip";
import { useHotkeys } from "@/lib/hooks/useHotkeys";
import { useListState } from "@/lib/hooks/useListState";
@@ -9,32 +10,42 @@ import { useDeckFromUrl } from "@/logic/deck/hooks/useDeckFromUrl";
import { useNotesOf } from "@/logic/note/hooks/useNotesOf";
import { NoteType } from "@/logic/note/note";
import { Note } from "@/logic/note/note";
+import { noteMatchesSearch } from "@/logic/note/search";
import { NoteSortFunction, NoteSorts } from "@/logic/note/sort";
import { DragDropContext, Droppable } from "@hello-pangea/dnd";
import {
IconCalendar,
IconMenuOrder,
+ IconSearch,
IconTextCaption,
} from "@tabler/icons-react";
-import { useEffect, useState } from "react";
+import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import NotebookCard from "./NotebookCard";
-import "./NotebookView.css";
+import CardItem from "./CardItem";
+import "./CardsView.css";
-const BASE = "notebook";
-const NOTEBOOK_LIMIT = 1000;
+const BASE = "cards-view";
+const CARDS_LIMIT = 5000;
-export default function NotebookView() {
+export default function CardsView() {
const [deck] = useDeckFromUrl();
const [excludeSubDecks, setExcludeSubDecks] = useState(false);
const [showAnswer, setShowAnswer] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const deferredSearchQuery = useDeferredValue(searchQuery);
- const [notes] = useNotesOf(deck, excludeSubDecks, NOTEBOOK_LIMIT);
+ const [notes] = useNotesOf(deck, excludeSubDecks, CARDS_LIMIT);
+ const filteredNotes = useMemo(
+ () => filterNotes(notes ?? [], deferredSearchQuery),
+ [notes, deferredSearchQuery]
+ );
const [sortOption, setSortOption] = useState
(sortOptions[0]);
const [sortOrder] = useState<1 | -1>(1);
- const [sortedNotes, setSortedNotes] = useState[]>(notes ?? []);
+ const [sortedNotes, setSortedNotes] = useState[]>(
+ filteredNotes ?? []
+ );
const [useCustomSort, setUseCustomSort] = useState(false);
const [customOrderTouched, setCustomOrderTouched] = useState(false);
@@ -49,25 +60,34 @@ export default function NotebookView() {
useEffect(() => {
setUseCustomSort(sortOption.value === "custom_order");
setSortedNotes(
- (notes ?? []).slice(0).sort(sortOption.sortFunction(sortOrder))
+ filteredNotes.slice(0).sort(sortOption.sortFunction(sortOrder))
);
- }, [notes, sortOption, sortOrder, setSortedNotes]);
+ }, [filteredNotes, sortOption, sortOrder, setSortedNotes]);
+
+ const omittedNoteCount = Math.max((deck?.notes.length ?? 0) - CARDS_LIMIT, 0);
return (
+ setSearchQuery(event.currentTarget.value)}
+ placeholder="Search front and back..."
+ leftSection={ }
+ aria-label="Search cards"
+ />
-
- {deck?.notes && deck?.notes?.length > NOTEBOOK_LIMIT && (
+ {omittedNoteCount > 0 && (
- Currently there is a limit of {NOTEBOOK_LIMIT} notes displayed.{" "}
- {deck.notes.length - NOTEBOOK_LIMIT} notes have been omitted.
+ {`Currently there is a limit of ${CARDS_LIMIT} cards displayed. ${omittedNoteCount} cards have been omitted.`}
)}
{useCustomSort ? (
@@ -93,7 +113,7 @@ export default function NotebookView() {
);
}}
>
-
+
{(provided) => (
{state.map((card, index) => (
-
{sortedNotes.map((note, index) => (
- [], query: string) {
+ return notes.filter((note) => noteMatchesSearch(note, query));
+}
+
interface SortOption {
value: string;
icon: React.ComponentType;
@@ -186,7 +210,7 @@ function SortSelect({
);
}
-function NotebookMenu({
+function CardsMenu({
excludeSubDecks,
setExcludeSubDecks,
showAnswer,
@@ -209,7 +233,7 @@ function NotebookMenu({
setExcludeSubDecks(!excludeSubDecks);
}}
>
- {t("notebook.options.exclude-subdecks")}
+ {t("cards.options.exclude-subdecks")}
- {t("notebook.options.show-answer")}
+ {t("cards.options.show-answer")}
diff --git a/src/app/deck/DeckView.tsx b/src/app/deck/DeckView.tsx
index 78480203..c0831579 100644
--- a/src/app/deck/DeckView.tsx
+++ b/src/app/deck/DeckView.tsx
@@ -13,7 +13,7 @@ import { useSuperDecks } from "@/logic/deck/hooks/useSuperDecks";
import { IconPlus } from "@tabler/icons-react";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
-import NotebookView from "../notebook/NotebookView";
+import CardsView from "../cards/CardsView";
import { AppHeaderContent } from "../shell/Header/Header";
import DeckMenu from "./DeckMenu";
import "./DeckView.css";
@@ -98,8 +98,8 @@ function DeckView() {
)}
-
- {t("deck.notebook.title")}
+
+ {t("deck.cards.title")}
{(deck?.notes.length as number) > 0 && (
-
-
+
+
diff --git a/src/app/editor/NewNotesView.css b/src/app/editor/NewNotesView.css
index 92aa3598..be799ab3 100644
--- a/src/app/editor/NewNotesView.css
+++ b/src/app/editor/NewNotesView.css
@@ -4,7 +4,7 @@
right: 0;
left: 0;
bottom: 0;
- padding-top: 3.6875rem;
+ padding-top: calc(var(--app-header-height) + var(--safe-area-top));
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
@@ -29,6 +29,7 @@
.new-notes-view__content {
flex-grow: 1;
padding: var(--spacing-lg);
+ overflow-y: auto;
}
.new-notes-view__editor-header {
@@ -50,9 +51,24 @@
.new-notes-view__footer {
padding: var(--spacing-md);
+ padding-bottom: max(var(--spacing-md), var(--safe-area-bottom));
border-top: 1px solid var(--theme-neutral-200);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--theme-neutral-50);
}
+
+@media (max-width: 575.98px) {
+ .new-notes-view__content {
+ padding: var(--spacing-md);
+ }
+
+ .new-notes-view__editor-header {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ gap: var(--spacing-sm);
+ margin-bottom: var(--spacing-md);
+ }
+}
diff --git a/src/app/editor/NewNotesView.tsx b/src/app/editor/NewNotesView.tsx
index 0e834fdb..c0fa8ef4 100644
--- a/src/app/editor/NewNotesView.tsx
+++ b/src/app/editor/NewNotesView.tsx
@@ -61,7 +61,7 @@ function NewNotesView() {
focusSelectNoteType,
})
: null;
- }, [deck, noteType, setNoteType, focusSelectNoteType]);
+ }, [deck, noteType, requestedFinish, setNoteType, focusSelectNoteType]);
if (isReady && !deck) {
return ;
@@ -89,6 +89,7 @@ function NewNotesView() {
label={t("note.new.adding-to-deck", { deckName: deck.name })}
decks={decks}
disableAll
+ selectedValue={deck.id}
onSelect={(deckId) => navigate(`/new/${deckId}`)}
/>
- note.sortField.toLowerCase().includes(filter.toLowerCase()) &&
+ noteMatchesSearch(note, filter) &&
(deckId === undefined || note.deck === deckId)
)
.toArray()
diff --git a/src/app/learn/FinishedLearningView/FinishedLearningView.css b/src/app/learn/FinishedLearningView/FinishedLearningView.css
index 7c5d0f32..d2eb0e44 100644
--- a/src/app/learn/FinishedLearningView/FinishedLearningView.css
+++ b/src/app/learn/FinishedLearningView/FinishedLearningView.css
@@ -1,6 +1,6 @@
.finished-learning-view {
- position: fixed;
- inset: 0;
+ min-height: 100%;
+ width: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -10,6 +10,7 @@
padding-right: max(var(--spacing-3xl), var(--safe-area-right));
padding-bottom: max(var(--spacing-3xl), var(--safe-area-bottom));
background-color: var(--theme-neutral);
+ overflow-y: auto;
}
.finished-learning-view__content {
@@ -79,13 +80,15 @@
margin-top: var(--spacing-3xl);
}
-/*@media (max-width: 768px) {
+@media (max-width: 768px) {
.finished-learning-view {
padding: var(--spacing-2xl);
+ align-items: flex-start;
}
.finished-learning-view__content {
gap: var(--spacing-3xl);
+ padding-block: var(--spacing-xl);
}
.finished-learning-view__title {
@@ -108,6 +111,8 @@
@media (max-width: 480px) {
.finished-learning-view {
padding: var(--spacing-xl);
+ padding-top: max(var(--spacing-xl), var(--safe-area-top));
+ padding-bottom: max(var(--spacing-xl), var(--safe-area-bottom));
}
.finished-learning-view__title {
@@ -121,6 +126,7 @@
.finished-learning-view__stats {
grid-template-columns: 1fr;
gap: var(--spacing-xl);
+ margin-top: var(--spacing-md);
}
.finished-learning-view__stat-value {
@@ -130,4 +136,4 @@
.finished-learning-view__stat-label {
font-size: 0.75rem;
}
-}*/
+}
diff --git a/src/app/notebook/NotebookView.css b/src/app/notebook/NotebookView.css
deleted file mode 100644
index 147a2270..00000000
--- a/src/app/notebook/NotebookView.css
+++ /dev/null
@@ -1,35 +0,0 @@
-.notebook {
- display: flex;
- flex-direction: column;
- gap: var(--spacing-sm);
- width: 100%;
- max-width: 100%;
-}
-
-.notebook__toolbar {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: var(--spacing-xs);
- flex-wrap: nowrap;
-}
-
-.notebook__limit-notice {
- color: var(--theme-neutral-500);
- text-align: center;
- padding: var(--spacing-sm);
-}
-
-.notebook__list > * {
- content-visibility: auto;
- contain-intrinsic-size: auto 200px;
-}
-
-.notebook__card-wrapper {
- transition: opacity 150ms ease;
- margin-bottom: var(--spacing-sm);
-}
-
-.notebook__card-wrapper--dragging {
- opacity: 0.6;
-}
diff --git a/src/app/notebook/NotebookView.module.css b/src/app/notebook/NotebookView.module.css
deleted file mode 100644
index d9f6405a..00000000
--- a/src/app/notebook/NotebookView.module.css
+++ /dev/null
@@ -1,43 +0,0 @@
-.card {
- padding: var(--spacing-sm);
- border: 1px solid var(--theme-neutral-300);
- border-radius: var(--radius-md);
- background: var(--theme-neutral-50);
- cursor: pointer;
- transition: box-shadow 150ms ease;
-}
-
-.card:hover {
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-}
-
-.card p,
-.card div,
-.card ul,
-.card ol {
- margin: 0;
-}
-
-.card ul {
- padding-inline-start: 1.25rem;
-}
-
-.card img {
- width: 75%;
-}
-
-.cardWrapper {
- margin-bottom: var(--spacing-xs);
- background: transparent;
- border-radius: var(--radius-sm);
- transition: box-shadow 150ms ease;
-}
-
-.cardWrapper:hover {
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
-}
-
-.cardWrapper.dragging {
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- opacity: 0.5;
-}
diff --git a/src/app/settings/importexport/ImportButton.tsx b/src/app/settings/importexport/ImportButton.tsx
index f7c26eb4..2be8ee5d 100644
--- a/src/app/settings/importexport/ImportButton.tsx
+++ b/src/app/settings/importexport/ImportButton.tsx
@@ -32,15 +32,9 @@ export default function ImportButton({
setImportStatus("error");
}
}}
+ leftSection={isLoading ? : undefined}
>
- {isLoading ? (
- <>
-
- Importing...
- >
- ) : (
- "Import and Add Cards"
- )}
+ {isLoading ? "Importing..." : "Import and Add Cards"}
);
}
diff --git a/src/app/settings/importexport/ImportFromJSON.tsx b/src/app/settings/importexport/ImportFromJSON.tsx
index 47e9f929..19585f43 100644
--- a/src/app/settings/importexport/ImportFromJSON.tsx
+++ b/src/app/settings/importexport/ImportFromJSON.tsx
@@ -3,12 +3,19 @@ import { Button } from "@/components/ui/Button";
import { Select } from "@/components/ui/Select";
import { Stack } from "@/components/ui/Stack";
import { Text } from "@/components/ui/Text";
-import { Deck } from "@/logic/deck/deck";
+import type { Deck } from "@/logic/deck/deck";
+import { BasicNoteTypeAdapter } from "@/logic/type-implementations/normal/BasicNote";
import { IconChevronRight } from "@tabler/icons-react";
import { useState } from "react";
import FileImport from "./FileImport";
import ImportButton from "./ImportButton";
import { ImportFromSourceProps, ImportStatus } from "./ImportModal";
+import { bulkImportBasicNotes } from "./bulkImportBasicNotes";
+import {
+ type ExtractedCrowdAnkiData,
+ importCrowdAnkiCards,
+ parseCrowdAnkiFile,
+} from "./crowdAnkiImport";
interface ImportFromJSONProps extends ImportFromSourceProps {}
@@ -22,9 +29,8 @@ export default function ImportFromJSON({
deck,
}: ImportFromJSONProps) {
const [step, setStep] = useState<"selectFile" | "options">("selectFile");
- const [extractedData, setExtractedData] = useState(
- null
- );
+ const [extractedData, setExtractedData] =
+ useState(null);
return (
{step === "selectFile" || !file ? (
@@ -44,9 +50,9 @@ export default function ImportFromJSON({
}
onClick={async () => {
- let ed: ExtractedData | null = null;
+ let ed: ExtractedCrowdAnkiData | null = null;
try {
- ed = await parseFile(fileText);
+ ed = parseCrowdAnkiFile(fileText);
} catch {
setImportStatus("error");
return;
@@ -78,55 +84,13 @@ export default function ImportFromJSON({
);
}
-interface ExtractedData {
- description: string;
- name: string;
- fields: { label: string; value: string }[];
- cards: { fields: string[] }[];
- warnMessages: string[];
-}
-
-async function parseFile(fileText: string | null): Promise {
- if (!fileText) {
- throw new Error("This file has no contents.");
- }
- const jsonObject = JSON.parse(fileText);
- const warnMessages: string[] = [];
- if (jsonObject.__type__ !== "Deck") {
- throw new Error("At the moment only decks can be imported from JSON");
- }
- const noteModels = jsonObject.note_models;
- if (!noteModels || noteModels.length === 0) {
- throw new Error("No note models found");
- } else if (noteModels.length > 1) {
- warnMessages.push(
- "Multiple note models found, only cards of the first one will be used"
- );
- }
- const firstNoteModel = noteModels[0];
- const cards = jsonObject.notes.map((note: any) => {
- return {
- fields: note.fields,
- };
- });
- return {
- description: jsonObject.desc,
- name: jsonObject.name,
- fields: firstNoteModel.flds.map((field: any) => ({
- label: field.name,
- value: field.ord.toString(),
- })),
- cards,
- warnMessages,
- };
-}
-
function ImportOptions({
extractedData,
importStatus,
setImportStatus,
+ deck,
}: {
- extractedData: ExtractedData | null;
+ extractedData: ExtractedCrowdAnkiData | null;
importStatus: ImportStatus;
setImportStatus: (status: ImportStatus) => void;
deck?: Deck;
@@ -156,10 +120,19 @@ function ImportOptions({
onChange={(value) => setBackField(value || null)}
/>
console.log("not supported right now")}
+ importFunction={() =>
+ importCrowdAnkiCards(
+ extractedData,
+ deck,
+ frontField,
+ backField,
+ BasicNoteTypeAdapter.createNote,
+ bulkImportBasicNotes
+ )
+ }
importStatus={importStatus}
setImportStatus={setImportStatus}
- disabled={!frontField || !backField}
+ disabled={!frontField || !backField || !deck}
/>
);
diff --git a/src/app/settings/importexport/bulkImportBasicNotes.ts b/src/app/settings/importexport/bulkImportBasicNotes.ts
new file mode 100644
index 00000000..645e6ef4
--- /dev/null
+++ b/src/app/settings/importexport/bulkImportBasicNotes.ts
@@ -0,0 +1,59 @@
+import { HTMLtoPreviewString } from "@/logic/card/card";
+import { createCardSkeleton } from "@/logic/card/createCardSkeleton";
+import { db } from "@/logic/db";
+import type { Deck } from "@/logic/deck/deck";
+import { invalidateDeckStatsCache } from "@/logic/deck/deckStatsCacheManager";
+import { createNoteSkeleton } from "@/logic/note/createNoteSkeleton";
+import { NoteType } from "@/logic/note/note";
+
+interface BasicNoteImport {
+ front: string;
+ back: string;
+}
+
+export async function bulkImportBasicNotes(
+ params: BasicNoteImport[],
+ deck: Deck
+) {
+ if (params.length === 0) {
+ return;
+ }
+
+ const notes = params.map((card) => {
+ const content = {
+ type: NoteType.Basic,
+ front: card.front,
+ back: card.back,
+ };
+
+ return {
+ ...createNoteSkeleton(deck.id),
+ content,
+ sortField: HTMLtoPreviewString(card.front),
+ };
+ });
+
+ const cards = notes.map((note) => ({
+ ...createCardSkeleton(),
+ deck: deck.id,
+ note: note.id,
+ content: { type: NoteType.Basic },
+ }));
+
+ const nextNoteIds = notes.map((note) => note.id);
+ const nextCardIds = cards.map((card) => card.id);
+
+ await db.transaction("rw", db.notes, db.cards, db.decks, async () => {
+ await db.notes.bulkAdd(notes);
+ await db.cards.bulkAdd(cards);
+ await db.decks.update(deck.id, {
+ notes: [...deck.notes, ...nextNoteIds],
+ cards: [...deck.cards, ...nextCardIds],
+ });
+ });
+
+ deck.notes.push(...nextNoteIds);
+ deck.cards.push(...nextCardIds);
+
+ await invalidateDeckStatsCache(deck.id);
+}
diff --git a/src/app/settings/importexport/crowdAnkiImport.test.ts b/src/app/settings/importexport/crowdAnkiImport.test.ts
new file mode 100644
index 00000000..ff537c7c
--- /dev/null
+++ b/src/app/settings/importexport/crowdAnkiImport.test.ts
@@ -0,0 +1,207 @@
+import type { Deck } from "@/logic/deck/deck";
+import { describe, expect, it, vi } from "vitest";
+import {
+ type ExtractedCrowdAnkiData,
+ importCrowdAnkiCards,
+ parseCrowdAnkiFile,
+} from "./crowdAnkiImport";
+
+const sampleCrowdAnkiDeck = {
+ __type__: "Deck",
+ name: "English",
+ desc: "Vocabulary cards",
+ note_models: [
+ {
+ flds: [
+ { name: "Front", ord: 0 },
+ { name: "Back", ord: 1 },
+ { name: "Extra", ord: 2 },
+ ],
+ },
+ ],
+ notes: [
+ {
+ fields: ["Solstice", "Солнцестояние", "noun"],
+ },
+ {
+ fields: ["reluctance", "Нежелание", ""],
+ },
+ ],
+};
+
+const deck: Deck = {
+ id: "deck-id",
+ name: "English",
+ subDecks: [],
+ cards: [],
+ notes: [],
+ options: {
+ dailyNewCards: 20,
+ newToReviewRatio: 1,
+ },
+};
+
+describe("parseCrowdAnkiFile", () => {
+ it("extracts fields and notes from a CrowdAnki deck", () => {
+ const parsed = parseCrowdAnkiFile(JSON.stringify(sampleCrowdAnkiDeck));
+
+ expect(parsed).toEqual({
+ description: "Vocabulary cards",
+ name: "English",
+ fields: [
+ { label: "Front", value: "0" },
+ { label: "Back", value: "1" },
+ { label: "Extra", value: "2" },
+ ],
+ cards: [
+ { fields: { "0": "Solstice", "1": "Солнцестояние", "2": "noun" } },
+ { fields: { "0": "reluctance", "1": "Нежелание", "2": "" } },
+ ],
+ warnMessages: [],
+ });
+ });
+
+ it("extracts cards from Anki-style JSON arrays", () => {
+ const ankiCards = [
+ {
+ deckName: "English Words",
+ modelName: "Basic",
+ fields: {
+ Front: "Solstice",
+ Back: "Солнцестояние",
+ },
+ },
+ ];
+
+ expect(parseCrowdAnkiFile(JSON.stringify(ankiCards))).toEqual({
+ description: "",
+ name: "English Words",
+ fields: [
+ { label: "Front", value: "Front" },
+ { label: "Back", value: "Back" },
+ ],
+ cards: [
+ {
+ fields: {
+ Front: "Solstice",
+ Back: "Солнцестояние",
+ },
+ },
+ ],
+ warnMessages: [],
+ });
+ });
+
+ it("reports multiple note models while still using the first model", () => {
+ const parsed = parseCrowdAnkiFile(
+ JSON.stringify({
+ ...sampleCrowdAnkiDeck,
+ note_models: [
+ sampleCrowdAnkiDeck.note_models[0],
+ { flds: [{ name: "Other", ord: 0 }] },
+ ],
+ })
+ );
+
+ expect(parsed.warnMessages).toEqual([
+ "Multiple note models found, only cards of the first one will be used.",
+ ]);
+ expect(parsed.fields[0]).toEqual({ label: "Front", value: "0" });
+ });
+});
+
+describe("importCrowdAnkiCards", () => {
+ it("creates basic notes using the selected front and back fields", async () => {
+ const createBasicNote = vi.fn().mockResolvedValue("note-id");
+ const parsed = parseCrowdAnkiFile(JSON.stringify(sampleCrowdAnkiDeck));
+
+ await importCrowdAnkiCards(parsed, deck, "0", "1", createBasicNote);
+
+ expect(createBasicNote).toHaveBeenCalledTimes(2);
+ expect(createBasicNote).toHaveBeenNthCalledWith(
+ 1,
+ {
+ front: "Solstice",
+ back: "Солнцестояние",
+ },
+ deck
+ );
+ expect(createBasicNote).toHaveBeenNthCalledWith(
+ 2,
+ {
+ front: "reluctance",
+ back: "Нежелание",
+ },
+ deck
+ );
+ });
+
+ it("skips cards missing the selected fields", async () => {
+ const createBasicNote = vi.fn().mockResolvedValue("note-id");
+ const parsed: ExtractedCrowdAnkiData = {
+ ...parseCrowdAnkiFile(JSON.stringify(sampleCrowdAnkiDeck)),
+ cards: [
+ { fields: { "0": "Front only" } },
+ { fields: { "0": "Front", "1": "Back" } },
+ ],
+ };
+
+ await importCrowdAnkiCards(parsed, deck, "0", "1", createBasicNote);
+
+ expect(createBasicNote).toHaveBeenCalledTimes(1);
+ expect(createBasicNote).toHaveBeenCalledWith(
+ { front: "Front", back: "Back" },
+ deck
+ );
+ });
+
+ it("supports field-name mappings from Anki-style JSON arrays", async () => {
+ const createBasicNote = vi.fn().mockResolvedValue("note-id");
+ const parsed = parseCrowdAnkiFile(
+ JSON.stringify([
+ {
+ deckName: "English Words",
+ fields: {
+ Front: "to cast about for smth",
+ Back: "Искать, подыскивать",
+ },
+ },
+ ])
+ );
+
+ await importCrowdAnkiCards(parsed, deck, "Front", "Back", createBasicNote);
+
+ expect(createBasicNote).toHaveBeenCalledWith(
+ {
+ front: "to cast about for smth",
+ back: "Искать, подыскивать",
+ },
+ deck
+ );
+ });
+
+ it("uses the bulk creator when one is provided", async () => {
+ const createBasicNote = vi.fn().mockResolvedValue("note-id");
+ const createBasicNotes = vi.fn().mockResolvedValue(undefined);
+ const parsed = parseCrowdAnkiFile(JSON.stringify(sampleCrowdAnkiDeck));
+
+ await importCrowdAnkiCards(
+ parsed,
+ deck,
+ "0",
+ "1",
+ createBasicNote,
+ createBasicNotes
+ );
+
+ expect(createBasicNote).not.toHaveBeenCalled();
+ expect(createBasicNotes).toHaveBeenCalledTimes(1);
+ expect(createBasicNotes).toHaveBeenCalledWith(
+ [
+ { front: "Solstice", back: "Солнцестояние" },
+ { front: "reluctance", back: "Нежелание" },
+ ],
+ deck
+ );
+ });
+});
diff --git a/src/app/settings/importexport/crowdAnkiImport.ts b/src/app/settings/importexport/crowdAnkiImport.ts
new file mode 100644
index 00000000..8dfcfec4
--- /dev/null
+++ b/src/app/settings/importexport/crowdAnkiImport.ts
@@ -0,0 +1,161 @@
+import type { Deck } from "@/logic/deck/deck";
+
+export interface ExtractedCrowdAnkiData {
+ description: string;
+ name: string;
+ fields: { label: string; value: string }[];
+ cards: { fields: Record }[];
+ warnMessages: string[];
+}
+
+interface CrowdAnkiNoteModelField {
+ name: string;
+ ord: number;
+}
+
+interface CrowdAnkiNote {
+ fields: string[];
+}
+
+interface CrowdAnkiDeck {
+ __type__: string;
+ desc?: string;
+ name?: string;
+ note_models?: { flds?: CrowdAnkiNoteModelField[] }[];
+ notes?: CrowdAnkiNote[];
+}
+
+interface AnkiJsonCard {
+ deckName?: string;
+ fields?: Record;
+ modelName?: string;
+ tags?: string[];
+}
+
+type CreateBasicNote = (
+ params: { front: string; back: string },
+ deck: Deck
+) => Promise | undefined;
+
+type CreateBasicNotes = (
+ params: { front: string; back: string }[],
+ deck: Deck
+) => Promise;
+
+export function parseCrowdAnkiFile(fileText: string | null) {
+ if (!fileText) {
+ throw new Error("This file has no contents.");
+ }
+
+ const jsonObject = JSON.parse(fileText) as CrowdAnkiDeck | AnkiJsonCard[];
+ const warnMessages: string[] = [];
+
+ if (Array.isArray(jsonObject)) {
+ return parseAnkiJsonCards(jsonObject);
+ }
+
+ if (jsonObject.__type__ !== "Deck") {
+ throw new Error("At the moment only CrowdAnki decks can be imported.");
+ }
+
+ const noteModels = jsonObject.note_models;
+ if (!noteModels || noteModels.length === 0) {
+ throw new Error("No note models found.");
+ }
+
+ if (noteModels.length > 1) {
+ warnMessages.push(
+ "Multiple note models found, only cards of the first one will be used."
+ );
+ }
+
+ const firstNoteModel = noteModels[0];
+ const fields = firstNoteModel.flds;
+ if (!fields || fields.length < 2) {
+ throw new Error("The first note model must have at least two fields.");
+ }
+
+ const notes = jsonObject.notes;
+ if (!notes || notes.length === 0) {
+ throw new Error("No notes found.");
+ }
+
+ return {
+ description: jsonObject.desc ?? "",
+ name: jsonObject.name ?? "Imported Deck",
+ fields: fields.map((field) => ({
+ label: field.name,
+ value: field.ord.toString(),
+ })),
+ cards: notes.map((note) => ({
+ fields: Object.fromEntries(
+ note.fields.map((fieldValue, index) => [index.toString(), fieldValue])
+ ),
+ })),
+ warnMessages,
+ } satisfies ExtractedCrowdAnkiData;
+}
+
+function parseAnkiJsonCards(cards: AnkiJsonCard[]) {
+ if (cards.length === 0) {
+ throw new Error("No cards found.");
+ }
+
+ const firstCard = cards[0];
+ const fieldNames = Object.keys(firstCard.fields ?? {});
+ if (fieldNames.length < 2) {
+ throw new Error("Cards must have at least two fields.");
+ }
+
+ return {
+ description: "",
+ name: firstCard.deckName ?? "Imported Deck",
+ fields: fieldNames.map((fieldName) => ({
+ label: fieldName,
+ value: fieldName,
+ })),
+ cards: cards
+ .filter((card) => card.fields)
+ .map((card) => ({
+ fields: card.fields ?? {},
+ })),
+ warnMessages: [],
+ } satisfies ExtractedCrowdAnkiData;
+}
+
+export async function importCrowdAnkiCards(
+ extractedData: ExtractedCrowdAnkiData,
+ deck: Deck | undefined,
+ frontField: string | null,
+ backField: string | null,
+ createBasicNote: CreateBasicNote,
+ createBasicNotes?: CreateBasicNotes
+) {
+ if (!deck) {
+ throw new Error("No destination deck selected.");
+ }
+
+ if (!frontField || !backField) {
+ throw new Error("Front and back fields must be selected.");
+ }
+
+ const cards = extractedData.cards.flatMap((card) => {
+ const front = card.fields[frontField];
+ const back = card.fields[backField];
+
+ if (!front || !back) {
+ return [];
+ }
+
+ return [{ front, back }];
+ });
+
+ if (createBasicNotes) {
+ await createBasicNotes(cards, deck);
+ return;
+ }
+
+ for (const card of cards) {
+ await createBasicNote(card, deck);
+ }
+}
diff --git a/src/app/shell/Spotlight/SpotlightContent.tsx b/src/app/shell/Spotlight/SpotlightContent.tsx
index d1e4e378..70d5d865 100644
--- a/src/app/shell/Spotlight/SpotlightContent.tsx
+++ b/src/app/shell/Spotlight/SpotlightContent.tsx
@@ -1,7 +1,7 @@
import { IconButton } from "@/components/ui";
import { Kbd } from "@/components/ui/Kbd";
-import { getAdapter } from "@/logic/NoteTypeAdapter";
import { useDecks } from "@/logic/deck/hooks/useDecks";
+import { getNotePreviewText } from "@/logic/note/preview";
import { IconCards, IconSearch, IconSquare, IconX } from "@tabler/icons-react";
import cx from "clsx";
import { t } from "i18next";
@@ -63,7 +63,7 @@ export function SpotlightContent({
group: "Notes",
actions: filteredNotes.map((note) => ({
id: note.id,
- label: getAdapter(note).getSortFieldFromNoteContent(note.content),
+ label: getNotePreviewText(note),
description: note.breadcrumb.join(" > "),
onClick: () => navigate(`/notes?deck=${note.deck}¬e=${note.id}`),
leftSection: ,
diff --git a/src/app/shell/Spotlight/useSearchNote.ts b/src/app/shell/Spotlight/useSearchNote.ts
index b2a7f0b9..8ed059fd 100644
--- a/src/app/shell/Spotlight/useSearchNote.ts
+++ b/src/app/shell/Spotlight/useSearchNote.ts
@@ -1,8 +1,8 @@
-import { getAdapter } from "@/logic/NoteTypeAdapter";
import { getDeck } from "@/logic/deck/getDeck";
import { getSuperDecks } from "@/logic/deck/getSuperDecks";
import { useNotesWith } from "@/logic/note/hooks/useNotesWith";
import { Note, NoteType } from "@/logic/note/note";
+import { noteMatchesSearch } from "@/logic/note/search";
import { NoteSorts } from "@/logic/note/sort";
import { useEffect, useState } from "react";
@@ -32,12 +32,7 @@ export function useSearchNote(filter: string): NoteWithPreview[] {
.toArray()
.then((m) =>
m
- .filter((note) =>
- getAdapter(note)
- .getSortFieldFromNoteContent(note.content)
- .toLowerCase()
- .includes(filter.toLowerCase())
- )
+ .filter((note) => noteMatchesSearch(note, filter))
.sort(NoteSorts.bySortField(1))
),
[filter]
diff --git a/src/components/ui/Modal.css b/src/components/ui/Modal.css
index d8c2e1df..25d3d005 100644
--- a/src/components/ui/Modal.css
+++ b/src/components/ui/Modal.css
@@ -160,10 +160,19 @@
}
.drawer-content--fullscreen {
- max-height: 100vh;
+ top: 0;
+ height: 100dvh;
+ max-height: 100dvh;
border-radius: 0;
padding-top: var(--safe-area-top);
padding-bottom: var(--safe-area-bottom);
+ padding-left: var(--safe-area-left);
+ padding-right: var(--safe-area-right);
+}
+
+.drawer-content--fullscreen .drawer-body {
+ min-height: 0;
+ padding: 0;
}
.drawer-handle {
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index a37d0069..04de738b 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -150,11 +150,11 @@ function MobileDrawer({
return (
!open && onClose()}>
-
+ {!fullscreen && }
-
+ {!fullscreen && }
{hasHeader && (
{title && (
diff --git a/src/components/ui/RichTextEditor.css b/src/components/ui/RichTextEditor.css
index ee17cf30..0d6eec70 100644
--- a/src/components/ui/RichTextEditor.css
+++ b/src/components/ui/RichTextEditor.css
@@ -17,6 +17,7 @@
background-color: transparent;
box-shadow: var(--inset-shadow-xs);
color: var(--theme-neutral-700);
+ font-family: var(--font-sans);
font-size: var(--font-size-md);
line-height: 1.55;
min-height: 100px;
diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json
index a1355437..f19abcd2 100644
--- a/src/i18n/locales/de/translation.json
+++ b/src/i18n/locales/de/translation.json
@@ -44,8 +44,8 @@
"submit": "Erstellen"
},
"no-decks-found": "Es gibt noch keine Stapel hier. Füge einen neuen hinzu!",
- "notebook": {
- "title": "Notizbuch"
+ "cards": {
+ "title": "Karten"
},
"rename": {
"new-name": "Neuer Name",
@@ -189,7 +189,7 @@
},
"type-double-sided": "Beidseitig"
},
- "notebook": {
+ "cards": {
"individual-show-answer-toggle-tooltip": "Klicke, um die Antwort anzuzeigen",
"options": {
"exclude-subdecks": "Unterstapel ausschließen",
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index bf8f76b7..b23650f0 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -45,8 +45,8 @@
"submit": "Create"
},
"no-decks-found": "There are no decks here. Click the button above to create one!",
- "notebook": {
- "title": "Notebook"
+ "cards": {
+ "title": "Cards"
},
"edit": {
"submit": "Save",
@@ -203,11 +203,11 @@
},
"type-double-sided": "Double Sided"
},
- "notebook": {
+ "cards": {
"individual-show-answer-toggle-tooltip": "Click to Show Answer",
"options": {
"exclude-subdecks": "Exclude subdecks",
- "menu": "Notebook menu",
+ "menu": "Cards menu",
"show-answer": "Show Answers"
}
},
diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json
index 0aa731c5..621f125b 100644
--- a/src/i18n/locales/pt/translation.json
+++ b/src/i18n/locales/pt/translation.json
@@ -45,8 +45,8 @@
"submit": "Criar"
},
"no-decks-found": "Não há novos decks aqui. Clique no botão acima para criar um!",
- "notebook": {
- "title": "Caderno"
+ "cards": {
+ "title": "Cartões"
},
"rename": {
"new-name": "Novo Nome",
@@ -156,11 +156,11 @@
},
"type-double-sided": "Face Dupla"
},
- "notebook": {
+ "cards": {
"individual-show-answer-toggle-tooltip": "Clique Para Mostrar a Resposta",
"options": {
"exclude-subdecks": "Excluir subdecks",
- "menu": "Menu do caderno",
+ "menu": "Menu de cartões",
"show-answer": "Mostrar respostas"
}
},
diff --git a/src/i18n/locales/sv/translation.json b/src/i18n/locales/sv/translation.json
index 194a2335..e1e7f72f 100644
--- a/src/i18n/locales/sv/translation.json
+++ b/src/i18n/locales/sv/translation.json
@@ -33,8 +33,8 @@
"new-subdeck": "Skapa nytt underdäck i {{superDeck}}",
"submit": "Skapa"
},
- "notebook": {
- "title": "Anteckningsbok"
+ "cards": {
+ "title": "Kort"
},
"rename": {
"new-name": "",
diff --git a/src/logic/NoteTypeAdapter.tsx b/src/logic/NoteTypeAdapter.tsx
index f3de649e..db25c275 100644
--- a/src/logic/NoteTypeAdapter.tsx
+++ b/src/logic/NoteTypeAdapter.tsx
@@ -24,15 +24,13 @@ export interface NoteTypeAdapter {
displayQuestion(
card: Card,
content?: NoteContent,
- // TODO: rename this parameter
- place?: "learn" | "notebook"
+ place?: "learn" | "cards"
): ReactNode;
displayAnswer(
card: Card,
content?: NoteContent,
- // TODO: rename this parameter
- place?: "learn" | "notebook"
+ place?: "learn" | "cards"
): ReactNode;
/**
diff --git a/src/logic/note/getNotesOf.ts b/src/logic/note/getNotesOf.ts
index 0b7d0058..ec0bf306 100644
--- a/src/logic/note/getNotesOf.ts
+++ b/src/logic/note/getNotesOf.ts
@@ -8,7 +8,8 @@ export async function getNotesOf(
limit?: number
): Promise[] | undefined> {
if (!deck) return undefined;
- let notes: Note[] = (await db.notes.bulkGet(deck.notes))
+ const limitedNoteIds = deck.notes.slice(0, limit ?? 999999);
+ let notes: Note[] = (await db.notes.bulkGet(limitedNoteIds))
.slice(0, limit ?? 999999)
.filter((n) => n !== undefined);
if (!directMembersOnly && notes.length < (limit ?? 999999)) {
diff --git a/src/logic/note/preview.test.ts b/src/logic/note/preview.test.ts
new file mode 100644
index 00000000..86682742
--- /dev/null
+++ b/src/logic/note/preview.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { NoteType } from "./note";
+import { getNotePreviewText } from "./preview";
+
+const baseNote = {
+ id: "note-id",
+ deck: "deck-id",
+ creationDate: new Date("2026-05-11"),
+ linkedNotes: [],
+ sortField: "front only",
+};
+
+describe("getNotePreviewText", () => {
+ it("combines front and back for basic notes", () => {
+ const note = {
+ ...baseNote,
+ content: {
+ type: NoteType.Basic,
+ front: "Solstice
",
+ back: "Солнцестояние ",
+ },
+ };
+
+ expect(getNotePreviewText(note)).toBe("Solstice — Солнцестояние");
+ });
+
+ it("combines both fields for double-sided notes", () => {
+ const note = {
+ ...baseNote,
+ content: {
+ type: NoteType.DoubleSided,
+ field1: "reluctance",
+ field2: "Нежелание, сопротивление",
+ },
+ };
+
+ expect(getNotePreviewText(note)).toBe(
+ "reluctance — Нежелание, сопротивление"
+ );
+ });
+});
diff --git a/src/logic/note/preview.ts b/src/logic/note/preview.ts
new file mode 100644
index 00000000..e0561f6b
--- /dev/null
+++ b/src/logic/note/preview.ts
@@ -0,0 +1,63 @@
+import { Note, NoteType } from "./note";
+
+export interface NotePreview {
+ front: string;
+ back?: string;
+}
+
+export function getNotePreview(note: Note): NotePreview {
+ switch (note.content.type) {
+ case NoteType.Basic: {
+ const content = note.content as Note["content"];
+ return {
+ front: htmlToPreviewText(content.front),
+ back: htmlToPreviewText(content.back),
+ };
+ }
+ case NoteType.DoubleSided: {
+ const content = note.content as Note["content"];
+ return {
+ front: htmlToPreviewText(content.field1),
+ back: htmlToPreviewText(content.field2),
+ };
+ }
+ case NoteType.Cloze: {
+ const content = note.content as Note["content"];
+ return {
+ front: htmlToPreviewText(revealClozeText(content.text)),
+ };
+ }
+ default:
+ return {
+ front: note.sortField,
+ };
+ }
+}
+
+export function getNotePreviewText(note: Note) {
+ const { front, back } = getNotePreview(note);
+ return [front, back].filter(Boolean).join(" — ");
+}
+
+export function htmlToPreviewText(value: string) {
+ return decodeCommonEntities(value)
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+function revealClozeText(value: string) {
+ return value.replace(/\{\{c\d::((?!\{\{|}}).)*\}\}/g, (match) =>
+ match.slice(6, -2)
+ );
+}
+
+function decodeCommonEntities(value: string) {
+ return value
+ .replace(/ /g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+}
diff --git a/src/logic/note/search.test.ts b/src/logic/note/search.test.ts
new file mode 100644
index 00000000..65abf88a
--- /dev/null
+++ b/src/logic/note/search.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { NoteType } from "./note";
+import { noteMatchesSearch } from "./search";
+
+const baseNote = {
+ id: "note-id",
+ deck: "deck-id",
+ creationDate: new Date("2026-05-11"),
+ linkedNotes: [],
+ sortField: "front only",
+};
+
+describe("noteMatchesSearch", () => {
+ it("matches basic notes by front and back content", () => {
+ const note = {
+ ...baseNote,
+ content: {
+ type: NoteType.Basic,
+ front: "reluctance",
+ back: "Нежелание, сопротивление",
+ },
+ };
+
+ expect(noteMatchesSearch(note, "reluctance")).toBe(true);
+ expect(noteMatchesSearch(note, "сопротивление")).toBe(true);
+ });
+
+ it("matches double-sided notes by both fields", () => {
+ const note = {
+ ...baseNote,
+ content: {
+ type: NoteType.DoubleSided,
+ field1: "Solstice",
+ field2: "Солнцестояние",
+ },
+ };
+
+ expect(noteMatchesSearch(note, "Solstice")).toBe(true);
+ expect(noteMatchesSearch(note, "Солнцестояние")).toBe(true);
+ });
+
+ it("strips html before matching", () => {
+ const note = {
+ ...baseNote,
+ content: {
+ type: NoteType.Basic,
+ front: "cast about
",
+ back: "подыскивать ",
+ },
+ };
+
+ expect(noteMatchesSearch(note, "подыскивать")).toBe(true);
+ });
+});
diff --git a/src/logic/note/search.ts b/src/logic/note/search.ts
new file mode 100644
index 00000000..0a28ace2
--- /dev/null
+++ b/src/logic/note/search.ts
@@ -0,0 +1,47 @@
+import { Note, NoteType } from "./note";
+
+export function noteMatchesSearch(note: Note, query: string) {
+ const normalizedQuery = normalizeSearchText(query);
+ if (!normalizedQuery) {
+ return true;
+ }
+
+ const haystack = getNoteSearchText(note);
+ return normalizedQuery
+ .split(/\s+/)
+ .filter(Boolean)
+ .every((term) => haystack.includes(term) || fuzzyIncludes(haystack, term));
+}
+
+export function getNoteSearchText(note: Note) {
+ const contentValues = Object.entries(note.content)
+ .filter(([key]) => key !== "type")
+ .map(([, value]) => (typeof value === "string" ? value : ""))
+ .join(" ");
+
+ return normalizeSearchText(`${note.sortField} ${contentValues}`);
+}
+
+export function normalizeSearchText(value: string) {
+ return value
+ .replace(/<[^>]*>/g, " ")
+ .replace(/ /g, " ")
+ .toLocaleLowerCase()
+ .trim();
+}
+
+function fuzzyIncludes(haystack: string, needle: string) {
+ let needleIndex = 0;
+
+ for (const char of haystack) {
+ if (char === needle[needleIndex]) {
+ needleIndex++;
+ }
+
+ if (needleIndex === needle.length) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/logic/type-implementations/double-sided/DoubleSidedNote.tsx b/src/logic/type-implementations/double-sided/DoubleSidedNote.tsx
index c69476f1..d3848a69 100644
--- a/src/logic/type-implementations/double-sided/DoubleSidedNote.tsx
+++ b/src/logic/type-implementations/double-sided/DoubleSidedNote.tsx
@@ -32,7 +32,7 @@ export const DoubleSidedNoteTypeAdapter: NoteTypeAdapter =
displayAnswer(
card: Card,
content?: NoteContent,
- _place?: "learn" | "notebook"
+ _place?: "learn" | "cards"
) {
const frontHtml = card.content.frontIsField1
? content?.field1