diff --git a/src/components/command-menu.tsx b/src/components/command-menu.tsx index 7b476acd..e0d1345a 100644 --- a/src/components/command-menu.tsx +++ b/src/components/command-menu.tsx @@ -10,7 +10,6 @@ import { useSaveNote } from "../hooks/note" import { useSearchNotes } from "../hooks/search" import { Note } from "../schema" import { formatDate, formatDateDistance, toDateString, toWeekString } from "../utils/date" -import { checkIfPinned } from "../utils/pin" import { pluralize } from "../utils/pluralize" import { CalendarDateIcon16, @@ -219,7 +218,7 @@ export function CommandMenu() { key={note.id} note={note} // Since they're all pinned, we don't need to show the pin icon - pinned={false} + hidePinIcon onSelect={handleSelect(() => navigate({ to: "/notes/$", @@ -312,7 +311,6 @@ export function CommandMenu() { navigate({ to: "/notes/$", @@ -408,22 +406,24 @@ function CommandItem({ children, value, icon, description, onSelect }: CommandIt function NoteItem({ note, - pinned, + hidePinIcon, onSelect, }: { note: Note - pinned: boolean + hidePinIcon?: boolean onSelect: () => void }) { return ( } + icon={} onSelect={onSelect} > - {pinned ? : null} + {!hidePinIcon && note.pinned ? ( + + ) : null} {note.displayName} diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index 9374bad8..ba1c9146 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -754,7 +754,7 @@ function NoteLink({ id, text }: NoteLinkProps) { > {isFirst && note && online ? ( } + icon={} > {note.displayName} diff --git a/src/components/note-favicon.tsx b/src/components/note-favicon.tsx index 4f826ee4..65355f02 100644 --- a/src/components/note-favicon.tsx +++ b/src/components/note-favicon.tsx @@ -1,37 +1,28 @@ import React from "react" import { useNetworkState } from "react-use" -import { templateSchema } from "../schema" +import { Note } from "../schema" import { cx } from "../utils/cx" -import { isValidDateString, isValidWeekString } from "../utils/date" import { getLeadingEmoji } from "../utils/emoji" -import { parseNote } from "../utils/parse-note" import { GitHubAvatar } from "./github-avatar" import { CalendarDateIcon16, CalendarIcon16, NoteIcon16, NoteTemplateIcon16 } from "./icons" import { WebsiteFavicon } from "./website-favicon" type NoteFaviconProps = React.ComponentPropsWithoutRef<"span"> & { - noteId: string - content: string + note: Note defaultFavicon?: React.ReactNode } const _defaultFavicon = export const NoteFavicon = React.memo( - ({ - noteId, - content, - className, - defaultFavicon = _defaultFavicon, - ...props - }: NoteFaviconProps) => { + ({ note, className, defaultFavicon = _defaultFavicon, ...props }: NoteFaviconProps) => { + console.log("NoteFavicon", note) const { online } = useNetworkState() - const { frontmatter, title, url } = React.useMemo(() => parseNote(content), [content]) let icon = defaultFavicon // Emoji - const leadingEmoji = getLeadingEmoji(title) + const leadingEmoji = getLeadingEmoji(note.title) if (leadingEmoji) { icon = ( @@ -43,33 +34,35 @@ export const NoteFavicon = React.memo( } // Daily note - if (isValidDateString(noteId)) { - icon = + if (note.type === "daily") { + icon = ( + + ) } // Weekly note - if (isValidWeekString(noteId)) { + if (note.type === "weekly") { icon = } // GitHub - if (typeof frontmatter.github === "string" && online) { - icon = + if (typeof note.frontmatter.github === "string" && online) { + icon = } // URL - if (url && online) { - icon = + if (note.url && online) { + icon = } // Book - if (frontmatter.isbn && online) { + if (note.frontmatter.isbn && online) { icon = (
@@ -77,11 +70,7 @@ export const NoteFavicon = React.memo( } // Template - const { success: isTemplate } = templateSchema - .omit({ body: true }) - .safeParse(frontmatter.template) - - if (isTemplate) { + if (note.type === "template") { icon = } diff --git a/src/components/note-list.tsx b/src/components/note-list.tsx index 11187a06..e76c19c0 100644 --- a/src/components/note-list.tsx +++ b/src/components/note-list.tsx @@ -3,13 +3,12 @@ import React, { useMemo, useState } from "react" import { useInView } from "react-intersection-observer" import { useDebounce } from "use-debounce" import { parseQuery, useSearchNotes } from "../hooks/search" -import { checkIfPinned } from "../utils/pin" import { formatNumber, pluralize } from "../utils/pluralize" import { Button } from "./button" import { Dice } from "./dice" import { DropdownMenu } from "./dropdown-menu" import { IconButton } from "./icon-button" -import { XIcon12, GridIcon16, ListIcon16, PinFillIcon12, TagIcon16 } from "./icons" +import { GridIcon16, ListIcon16, PinFillIcon12, TagIcon16, XIcon12 } from "./icons" import { LinkHighlightProvider } from "./link-highlight-provider" import { NoteFavicon } from "./note-favicon" import { NotePreviewCard } from "./note-preview-card" @@ -257,8 +256,8 @@ export function NoteList({ }} className="focus-ring flex items-center rounded-lg p-3 leading-4 hover:bg-bg-secondary coarse:p-4" > - - {checkIfPinned(note.content) ? ( + + {note.pinned ? ( ) : null} diff --git a/src/components/note-preview-card.tsx b/src/components/note-preview-card.tsx index 2f69d5ca..f74acca6 100644 --- a/src/components/note-preview-card.tsx +++ b/src/components/note-preview-card.tsx @@ -13,7 +13,7 @@ import { useDeleteNote, useNoteById, useSaveNote } from "../hooks/note" import { NoteId } from "../schema" import { cx } from "../utils/cx" import { exportAsGist } from "../utils/export-as-gist" -import { checkIfPinned, togglePin } from "../utils/pin" +import { togglePin } from "../utils/pin" import { pluralize } from "../utils/pluralize" import { DropdownMenu } from "./dropdown-menu" import { IconButton } from "./icon-button" @@ -53,7 +53,6 @@ const _NotePreviewCard = React.memo(function NoteCard({ id }: NoteCardProps) { const githubUser = useAtomValue(githubUserAtom) const githubRepo = useAtomValue(githubRepoAtom) const isSignedOut = useAtomValue(isSignedOutAtom) - const isPinned = checkIfPinned(note?.content ?? "") const saveNote = useSaveNote() const deleteNote = useDeleteNote() const [isDropdownOpen, setIsDropdownOpen] = React.useState(false) @@ -80,11 +79,11 @@ const _NotePreviewCard = React.memo(function NoteCard({ id }: NoteCardProps) {
{ @@ -92,7 +91,7 @@ const _NotePreviewCard = React.memo(function NoteCard({ id }: NoteCardProps) { saveNote({ id, content: togglePin(note.content) }) }} > - {isPinned ? : } + {note.pinned ? : }
{note ? ( diff --git a/src/global-state.ts b/src/global-state.ts index b4e6d6b1..445ea721 100644 --- a/src/global-state.ts +++ b/src/global-state.ts @@ -2,7 +2,7 @@ import { Searcher } from "fast-fuzzy" import git, { WORKDIR } from "isomorphic-git" import { atom } from "jotai" import { atomWithMachine } from "jotai-xstate" -import { selectAtom, atomWithStorage } from "jotai/utils" +import { atomWithStorage, selectAtom } from "jotai/utils" import { assign, createMachine, raise } from "xstate" import { z } from "zod" import { @@ -27,11 +27,9 @@ import { isRepoSynced, } from "./utils/git" import { parseNote } from "./utils/parse-note" -import { checkIfPinned } from "./utils/pin" import { removeTemplateFrontmatter } from "./utils/remove-template-frontmatter" import { getSampleMarkdownFiles } from "./utils/sample-markdown-files" import { startTimer } from "./utils/timer" -import { removeLeadingEmoji } from "./utils/emoji" // ----------------------------------------------------------------------------- // Constants @@ -566,14 +564,7 @@ export const notesAtom = atom((get) => { for (const filepath in markdownFiles) { const id = filepath.replace(/\.md$/, "") const content = markdownFiles[filepath] - const parsedNote = parseNote(content) - const parsedTemplate = templateSchema - .omit({ body: true }) - .safeParse(parsedNote.frontmatter?.template) - const displayName = parsedTemplate.success - ? `${parsedTemplate.data.name} template` - : removeLeadingEmoji(parsedNote.title) || id - notes.set(id, { id, content, displayName, ...parsedNote, backlinks: [] }) + notes.set(id, parseNote(id, content)) } // Derive backlinks @@ -595,7 +586,7 @@ export const notesAtom = atom((get) => { export const pinnedNotesAtom = atom((get) => { const notes = get(notesAtom) - return [...notes.values()].filter((note) => checkIfPinned(note.content)).reverse() + return [...notes.values()].filter((note) => note.pinned).reverse() }) export const sortedNotesAtom = atom((get) => { diff --git a/src/hooks/search.ts b/src/hooks/search.ts index 763757e5..16b9ae16 100644 --- a/src/hooks/search.ts +++ b/src/hooks/search.ts @@ -164,7 +164,9 @@ export function testQualifiers(qualifiers: Qualifier[], item: Note) { break case "tasks": - value = qualifier.values.some((value) => isInRange(item.openTasks, value)) + value = qualifier.values.some((value) => + isInRange(item.tasks.filter((task) => !task.completed).length, value), + ) break case "no": @@ -174,8 +176,6 @@ export function testQualifiers(qualifiers: Qualifier[], item: Note) { case "backlinks": if (!("backlinks" in item)) return true return item.backlinks.length === 0 - case "tasks": - return item.openTasks === 0 case "tag": case "tags": return item.tags.length === 0 @@ -185,6 +185,11 @@ export function testQualifiers(qualifiers: Qualifier[], item: Note) { case "link": case "links": return item.links.length === 0 + case "task": + case "tasks": + return item.tasks.filter((task) => !task.completed).length === 0 + case "title": + return !item.title default: return !(value in frontmatter) } @@ -207,14 +212,22 @@ export function testQualifiers(qualifiers: Qualifier[], item: Note) { case "link": case "links": return item.links.length === 0 + case "task": case "tasks": - return item.openTasks === 0 + return item.tasks.filter((task) => !task.completed).length === 0 + case "title": + return !item.title default: return !(value in frontmatter) } }) break + case "is": + case "type": + value = qualifier.values.includes(item.type) + break + default: if (qualifier.key in frontmatter) { // Match if the item's frontmatter value is in the qualifier's values diff --git a/src/routes/notes_.$.tsx b/src/routes/notes_.$.tsx index 425d348f..058c3391 100644 --- a/src/routes/notes_.$.tsx +++ b/src/routes/notes_.$.tsx @@ -60,7 +60,8 @@ import { isValidWeekString, } from "../utils/date" import { exportAsGist } from "../utils/export-as-gist" -import { checkIfPinned, togglePin } from "../utils/pin" +import { parseNote } from "../utils/parse-note" +import { togglePin } from "../utils/pin" import { pluralize } from "../utils/pluralize" type RouteSearch = { @@ -84,28 +85,28 @@ const isRepoClonedAtom = selectAtom(globalStateMachineAtom, (state) => state.matches("signedIn.cloned"), ) -function PageTitle({ noteId }: { noteId: string }) { - if (isValidDateString(noteId)) { +function PageTitle({ note }: { note: Note }) { + if (note.type === "daily") { return ( - {noteId}.md + {note.displayName} · - {formatDateDistance(noteId)} + {formatDateDistance(note.id)} ) } - if (isValidWeekString(noteId)) { + if (note.type === "weekly") { return ( - {noteId}.md + {note.displayName} · - {formatWeekDistance(noteId)} + {formatWeekDistance(note.id)} ) } - return `${noteId}.md` + return note.displayName } function RouteComponent() { @@ -118,7 +119,7 @@ function RouteComponent() { } return ( - } icon={}> + }>
{/* TODO */}
) @@ -177,8 +178,8 @@ function NotePage() { ? renderTemplate(weeklyTemplate, { week: noteId ?? "" }) : "", }) - const isPinned = useMemo(() => checkIfPinned(editorValue), [editorValue]) const [editorSettings] = useEditorSettings() + const parsedNote = useMemo(() => parseNote(noteId ?? "", editorValue), [noteId, editorValue]) // Layout const { ref: containerRef, width: containerWidth = 0 } = useResizeObserver() @@ -315,14 +316,14 @@ function NotePage() { - {isPinned ? : null} + {parsedNote.pinned ? : null} - + {isDirty ? : null}
} - icon={} + icon={} actions={
{!note || isDirty ? ( @@ -379,13 +380,17 @@ function NotePage() { ) : null} : + parsedNote.pinned ? ( + + ) : ( + + ) } onSelect={() => { setEditorValue(togglePin(editorValue)) }} > - {isPinned ? "Unpin" : "Pin"} + {parsedNote.pinned ? "Unpin" : "Pin"} {containerWidth > 800 && ( diff --git a/src/schema.ts b/src/schema.ts index 71bf4143..c6952334 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -2,6 +2,13 @@ import { z } from "zod" export type NoteId = string +export type NoteType = "note" | "daily" | "weekly" | "template" + +export type Task = { + completed: boolean + text: string +} + export type Note = { /** The markdown file path without the extension (e.g. `foo/bar.md` → `foo/bar`) */ id: NoteId @@ -10,7 +17,9 @@ export type Note = { // ↓ Parsed from the content - /** Depending on the content, either the title, id, or template name */ + /** The type of the note */ + type: NoteType + /** Depending on the type, either the title, template name, or the date */ displayName: string /** The frontmatter of the markdown file */ frontmatter: Record @@ -18,12 +27,14 @@ export type Note = { title: string /** If the title contains a link (e.g. `# [title](url)`), we use that as the url */ url: string | null + /** If the note is pinned */ + pinned: boolean /** The ids of all notes that are linked to from this note */ links: NoteId[] dates: string[] tags: string[] - /** How many open tasks (`- [ ]`) the note has */ - openTasks: number + /** The tasks in the note (e.g. `- [ ] Do laundry` → `{ completed: false, text: "Do laundry" }`) */ + tasks: Task[] // ↓ Derived from links diff --git a/src/utils/parse-note.ts b/src/utils/parse-note.ts index c22ba1e0..79d7b53a 100644 --- a/src/utils/parse-note.ts +++ b/src/utils/parse-note.ts @@ -9,9 +9,17 @@ import { z } from "zod" import { embed, embedFromMarkdown } from "../remark-plugins/embed" import { tag, tagFromMarkdown } from "../remark-plugins/tag" import { wikilink, wikilinkFromMarkdown } from "../remark-plugins/wikilink" -import { NoteId } from "../schema" -import { getNextBirthday, isValidDateString, toDateStringUtc } from "./date" +import { Note, NoteId, NoteType, Task, Template, templateSchema } from "../schema" +import { + formatDate, + formatWeek, + getNextBirthday, + isValidDateString, + isValidWeekString, + toDateStringUtc, +} from "./date" import { parseFrontmatter } from "./parse-frontmatter" +import { removeLeadingEmoji } from "./emoji" /** * Extract metadata from a note. @@ -19,15 +27,17 @@ import { parseFrontmatter } from "./parse-frontmatter" * We memoize this function because it's called a lot and it's expensive. * We're intentionally sacrificing memory usage for runtime performance. */ -export const parseNote = memoize((text: string) => { +export const parseNote = memoize((id: NoteId, content: string): Note => { + let type: NoteType = "note" + let displayName = "Untitled note" let title = "" let url: string | null = null const tags = new Set() const dates = new Set() const links = new Set() - let openTasks = 0 + const tasks: Task[] = [] - const { frontmatter, content } = parseFrontmatter(text) + const { frontmatter, content: contentWithoutFrontmatter } = parseFrontmatter(content) function visitNode(node: Node) { switch (node.type) { @@ -69,8 +79,11 @@ export const parseNote = memoize((text: string) => { } case "listItem": { - if (node.checked === false) { - openTasks++ + if (typeof node.checked === "boolean") { + tasks.push({ + completed: node.checked === true, + text: toString(node), + }) } break } @@ -89,7 +102,7 @@ export const parseNote = memoize((text: string) => { ] const contentMdast = fromMarkdown( - content, + contentWithoutFrontmatter, // @ts-ignore TODO: Fix types { extensions, mdastExtensions }, ) @@ -97,7 +110,7 @@ export const parseNote = memoize((text: string) => { visit(contentMdast, visitNode) // Parse frontmatter as markdown to find things like wikilinks and tags - const frontmatterString = text.slice(0, text.length - content.length) + const frontmatterString = content.slice(0, content.length - contentWithoutFrontmatter.length) const frontmatterMdast = fromMarkdown( frontmatterString, // @ts-ignore TODO: Fix types @@ -147,13 +160,50 @@ export const parseNote = memoize((text: string) => { ) } + // Determine the type of the note + if (isValidDateString(id)) { + type = "daily" + } else if (isValidWeekString(id)) { + type = "weekly" + } else if (templateSchema.omit({ body: true }).safeParse(frontmatter.template).success) { + type = "template" + } + + switch (type) { + case "daily": + displayName = formatDate(id) + break + case "weekly": + displayName = formatWeek(id) + break + case "template": + displayName = (frontmatter.template as Template).name + break + case "note": + // If there's a title, use it as thedisplay name after removing any leading emoji + if (title) { + displayName = removeLeadingEmoji(title) + } + // If there's no title but the ID contains non-numeric characters, use that as the display name + else if (!/^\d+$/.test(id)) { + displayName = id + } + break + } + return { + id, + content, + type, + displayName, frontmatter, title, url, + pinned: frontmatter.pinned === true, dates: Array.from(dates), links: Array.from(links), tags: Array.from(tags), - openTasks, + tasks, + backlinks: [], } }) diff --git a/src/utils/pin.ts b/src/utils/pin.ts index ea41b241..623a2113 100644 --- a/src/utils/pin.ts +++ b/src/utils/pin.ts @@ -1,10 +1,3 @@ -import { parseFrontmatter } from "./parse-frontmatter" - -export function checkIfPinned(content: string): boolean { - const { frontmatter } = parseFrontmatter(content) - return frontmatter?.pinned === true -} - export function togglePin(content: string): string { // Define a regular expression to match the frontmatter block const frontmatterRegex = /^---\n([\s\S]*?)\n---/