Skip to content

Commit 0400339

Browse files
authored
Merge pull request #714 from vormadal/claude/notes-markdown-import-8kffgy
Add Markdown import to the notes importer
2 parents 89181a3 + edbf030 commit 0400339

16 files changed

Lines changed: 450 additions & 23 deletions

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,16 @@ relationships resolved without registering OOXML namespaces — matched by their
174174
literal `w:`-prefixed tag/attribute names, since that's how the browser's
175175
`DOMParser` preserves them), then run through `generateJSON(html,
176176
noteExtensions)` — the same schema the editor itself uses — so an imported
177-
document can never contain something the editor can't open.
177+
document can never contain something the editor can't open. A `.md`/`.markdown`
178+
file takes the same route via `marked` (`breaks: true`, so a note's line breaks
179+
survive as `<br>` and every remaining newline sits strictly between block tags),
180+
followed by a `DOMParser` pass that degrades what the schema has no node for:
181+
tables flatten to one paragraph per cell (as in `docx.ts`), GFM task items become
182+
``/`` text, and image references — markdown embeds no bytes — are kept only
183+
when absolute, with relative paths dropped and warned about. YAML front matter is
184+
stripped, its `title` preferred over the filename. The three-stage pipeline, and
185+
what adding a fourth format takes, are documented in
186+
[`anything-frontend/src/lib/agent.md`](anything-frontend/src/lib/agent.md).
178187

179188
All endpoints are under `/api/somethings`:
180189
- `GET /` — List all (non-deleted)
@@ -219,6 +228,7 @@ Non-obvious gotchas:
219228
- **A shared helper under `Anything.Application.Features` must not be named `*Query`, `*Command` or `*Handler`.** `CqrsPatternTests` and `NamingConventionTests` assert that *every* type in that namespace with one of those suffixes implements `IRequest`/`IRequestHandler`, so a plain static helper called e.g. `InventoryThumbnailQuery` fails the architecture tests with nothing wrong in the code itself. Name it for what it does (`InventoryThumbnailLookup`, `*Mapping`).
220229
- **Filter a nullable FK with a `List<int?>`, not `.Value`.** `ids.Contains(a.ItemId)` where `ids` is `List<int?>` translates to a plain `= ANY(@ids)`; `ids.Contains(a.ItemId.Value)` leans on EF's nullable unwrapping and can throw at translation time. Nothing local catches this — the unit tests' `AsAsyncQueryable` provider is LINQ-to-objects and executes untranslatable expressions happily, and `dotnet build` has no opinion. Same class of trap as the raw-SQL translations `SearchEndpointTests` exists to cover.
221230
- **Adding a constructor parameter to a query handler breaks its unit tests at compile time**, since `InventoryHandlerTests` and friends construct handlers positionally. A repository substitute also needs `.Query()` stubbed (`Substitute.For<IRepository<T>>()` returns null otherwise, and `ToListAsync` NREs) — `InventoryTestDoubles` in `InventoryHandlerTests.cs` is the shared factory for that.
231+
- **An ESM-only npm package breaks Jest, and `transformIgnorePatterns` can't fix it — add it to `transpilePackages` in `next.config.ts`.** Packages shipping only ESM (`marked`, `@microsoft/kiota-abstractions`) fail with `SyntaxError: Unexpected token 'export'` in Jest, because next/jest **hardcodes** `/node_modules/` as the first ignore pattern and only *appends* a custom `transformIgnorePatterns` (see `next/dist/build/jest/jest.js`) — a `/node_modules/(?!(pkg)/)` override in `jest.config.mjs` is silently outranked. Listing the package in `transpilePackages` is what makes next/jest emit `/node_modules/(?!.pnpm)(?!(pkg)/)` instead. `npm run build` passes either way, so only the Jest run catches it.
222232
- **Adding a key to `HomeCardKeys.All` breaks `HomePreferenceEndpointTests`**, which asserts the exact default card list and sort orders. Update it in the same change; the frontend's `DEFAULT_HOME_CARD_ORDER` in `anything-frontend/src/app/HomeCards.tsx` mirrors the same list and needs the matching entry.
223233
- **Model-changing pushes break `update-api-client` on the first push — this is expected.** When a push adds/changes an entity, `update-ef-migrations` and `update-api-client` run in parallel on the *same* pre-migration commit. `update-api-client` boots the API, whose startup `MigrateAsync` escalates `PendingModelChangesWarning` to a fatal error because the migration doesn't exist yet, so Swagger never comes up and Kiota generation times out (exit 124). `update-ef-migrations` meanwhile commits the migration. To regenerate the client you must re-trigger `update-api-client` on a commit that *already contains* the migration — and it only triggers on `src/Anything.{API,Contracts,Application,Core}/**` (NOT `src/Anything.Database/**`, where migrations live). Fix: after the migration lands, make a small backend-path change (e.g. XML-doc the new contracts) in your next push, then pull/rebase the regenerated client before validating the frontend.
224234
- **Snapshot bot `[skip ci]` commits don't re-trigger PR checks.** The PR's `Visual Snapshot Tests` check does NOT rerun on the baseline-containing commit — the PR stays red with the pre-baseline failure as its latest check. After the bot commit lands, push a small non-`[skip ci]` commit (docs, comment) to sync the PR and rerun checks on a tip that includes the baselines.

anything-frontend/e2e/visual.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,8 @@ test.describe("Visual Snapshots - Authenticated Pages", () => {
11231123
test("note import - pick files", async ({ page }) => {
11241124
await page.goto("/notes/import");
11251125
await page.waitForLoadState("networkidle");
1126-
await expect(page.getByText("Choose exported files")).toBeVisible();
1126+
await expect(page.getByRole("heading", { name: "Exporting from Samsung Notes" })).toBeVisible();
1127+
await expect(page.getByText(".txt, .md or .docx — you can pick several at once")).toBeVisible();
11271128
await expect(page).toHaveScreenshot("note-import-pick.png", screenshotOptions);
11281129
});
11291130

@@ -1141,6 +1142,22 @@ test.describe("Visual Snapshots - Authenticated Pages", () => {
11411142
await expect(page).toHaveScreenshot("note-import-review.png", screenshotOptions);
11421143
});
11431144

1145+
test("note import - markdown review", async ({ page }) => {
1146+
const markdown = "# Weekend\n\n- [x] book flight\n- [ ] pack\n\n![map](./img/map.png)";
1147+
await page.goto("/notes/import");
1148+
await page.waitForLoadState("networkidle");
1149+
await page.locator('input[type="file"]').setInputFiles([
1150+
{ name: "Weekend plans.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) },
1151+
{ name: "Reading list.markdown", mimeType: "text/markdown", buffer: Buffer.from("- Dune\n- Solaris") },
1152+
]);
1153+
await expect(page.getByText("Weekend plans")).toBeVisible();
1154+
await expect(page.getByText("Reading list")).toBeVisible();
1155+
// The relative image path can't be resolved from an exported .md, so this
1156+
// row carries the dropped-image warning while the other one doesn't.
1157+
await expect(page.getByText("Images stored next to this file weren't imported.")).toBeVisible();
1158+
await expect(page).toHaveScreenshot("note-import-markdown-review.png", screenshotOptions);
1159+
});
1160+
11441161
test("home page - notes card", async ({ page }) => {
11451162
await page.route("**/api/home/card-preferences**", (route) => {
11461163
if (route.request().method() === "GET") {
22.2 KB
Loading
2.03 KB
Loading

anything-frontend/next.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ const nextConfig: NextConfig = {
44
output: "standalone",
55
// Kiota ships its abstractions as ESM; transpiling it lets Jest (via next/jest)
66
// transform the package when hooks import runtime helpers like DateOnly directly.
7-
transpilePackages: ["@microsoft/kiota-abstractions"],
7+
// `marked` (the notes markdown importer) is ESM-only for the same reason —
8+
// next/jest hardcodes `/node_modules/` in transformIgnorePatterns unless a
9+
// package is listed here, so a jest.config override alone can't reach it.
10+
transpilePackages: ["@microsoft/kiota-abstractions", "marked"],
811
async redirects() {
912
return [
1013
{ source: '/shopping-lists', destination: '/lists', permanent: true },

anything-frontend/package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

anything-frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"fflate": "^0.8.3",
4848
"idb-keyval": "^6.3.0",
4949
"lucide-react": "^1.27.0",
50+
"marked": "^18.0.9",
5051
"next": "16.2.12",
5152
"react": "19.2.8",
5253
"react-dom": "19.2.8",

anything-frontend/src/app/notes/import/page.test.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,32 @@ describe("ImportNotesPage", () => {
5656
);
5757
});
5858

59+
it("imports a markdown file as a formatted note", async () => {
60+
const user = userEvent.setup();
61+
renderWithClient(<ImportNotesPage />);
62+
63+
const recipe = new File(["# Pancakes\n\n- flour\n- eggs"], "Pancakes.md", { type: "text/markdown" });
64+
await user.upload(pickFilesInput(), [recipe]);
65+
66+
await user.click(await screen.findByRole("button", { name: "Import 1 note" }));
67+
68+
await waitFor(() => expect(screen.getByText("1 note imported.")).toBeVisible());
69+
expect(mockNotesPost).toHaveBeenCalledWith(
70+
expect.objectContaining({ title: "Pancakes", contentJson: expect.stringContaining('"heading"') })
71+
);
72+
});
73+
74+
it("warns that a markdown file's local images can't come along", async () => {
75+
const user = userEvent.setup();
76+
renderWithClient(<ImportNotesPage />);
77+
78+
const withImage = new File(["Look:\n\n![kitchen](./img/kitchen.png)"], "House.md", { type: "text/markdown" });
79+
await user.upload(pickFilesInput(), [withImage]);
80+
81+
expect(await screen.findByText("Images stored next to this file weren't imported.")).toBeVisible();
82+
expect(screen.getByRole("button", { name: "Import 1 note" })).toBeEnabled();
83+
});
84+
5985
it("disables a file it can't parse and excludes it from the import count", async () => {
6086
renderWithClient(<ImportNotesPage />);
6187
const unsupported = new File(["%PDF"], "note.pdf", { type: "application/pdf" });
@@ -67,7 +93,7 @@ describe("ImportNotesPage", () => {
6793
Object.defineProperty(pickFilesInput(), "files", { value: [unsupported] });
6894
fireEvent.change(pickFilesInput());
6995

70-
expect(await screen.findByText("Only .txt and .docx files can be imported.")).toBeVisible();
96+
expect(await screen.findByText("Only .txt, .md and .docx files can be imported.")).toBeVisible();
7197
expect(screen.getByText("Nothing selected")).toBeVisible();
7298
});
7399

anything-frontend/src/app/notes/import/page.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,24 @@ export default function ImportNotesPage() {
8282
if (phase === "pick") {
8383
return (
8484
<div className="container mx-auto max-w-lg space-y-4 px-4 py-4">
85-
<PageTitle>Import from Samsung Notes</PageTitle>
85+
<PageTitle>Import notes</PageTitle>
8686
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:p-6 dark:border-gray-700 dark:bg-gray-800">
87-
<ol className="list-inside list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-400">
88-
{INSTRUCTIONS.map((step) => (
89-
<li key={step}>{step}</li>
90-
))}
91-
</ol>
87+
<div className="space-y-1">
88+
<h2 className="text-sm font-medium text-gray-900 dark:text-white">Exporting from Samsung Notes</h2>
89+
<ol className="list-inside list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-400">
90+
{INSTRUCTIONS.map((step) => (
91+
<li key={step}>{step}</li>
92+
))}
93+
</ol>
94+
</div>
9295
<label className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 p-8 text-gray-500 transition-colors hover:border-blue-500 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-400">
9396
<Upload className="h-8 w-8" />
9497
<span className="text-sm font-medium">Choose exported files</span>
95-
<span className="text-xs">.txt or .docx — you can pick several at once</span>
98+
<span className="text-xs">.txt, .md or .docx — you can pick several at once</span>
9699
<input
97100
type="file"
98101
multiple
99-
accept=".txt,.docx"
102+
accept=".txt,.md,.markdown,.docx"
100103
className="sr-only"
101104
onChange={(e) => handleFilesSelected(e.target.files)}
102105
/>

anything-frontend/src/app/notes/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { buttonVariants } from "@/components/ui/button";
1010
import { cn } from "@/lib/utils";
1111
import { ChevronRight, Plus, Upload } from "lucide-react";
1212

13+
const IMPORT_LABEL = "Import notes";
14+
1315
export default function NotesPage() {
1416
const router = useRouter();
1517
const { setHeaderActions } = useHeaderActions();
@@ -20,8 +22,8 @@ export default function NotesPage() {
2022
<div className="ml-auto flex items-center gap-1">
2123
<Link
2224
href="/notes/import"
23-
aria-label="Import from Samsung Notes"
24-
title="Import from Samsung Notes"
25+
aria-label={IMPORT_LABEL}
26+
title={IMPORT_LABEL}
2527
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
2628
>
2729
<Upload className="h-5 w-5" />

0 commit comments

Comments
 (0)