diff --git a/src/components/calendar-header.tsx b/src/components/calendar-header.tsx index 92f63be2..7aac5c9c 100644 --- a/src/components/calendar-header.tsx +++ b/src/components/calendar-header.tsx @@ -1,6 +1,8 @@ import { addDays, addWeeks, parseISO, startOfToday } from "date-fns" import { useNavigate, useSearch } from "@tanstack/react-router" import React from "react" +import { useBuildCalendarNoteId } from "../hooks/config" +import { getCalendarNoteBasename, isCalendarNoteId } from "../utils/config" import { formatDate, formatDateDistance, @@ -21,29 +23,31 @@ type CalendarHeaderProps = { export function CalendarHeader({ activeNoteId }: CalendarHeaderProps) { const navigate = useNavigate() const searchParams = useSearch({ strict: false }) - const isWeekly = isValidWeekString(activeNoteId) + const buildId = useBuildCalendarNoteId() - const primaryText = isWeekly ? formatWeek(activeNoteId) : formatDate(activeNoteId) - const secondaryText = isWeekly - ? formatWeekDistance(activeNoteId) - : formatDateDistance(activeNoteId) + // Get the basename (date/week part) for formatting + const basename = getCalendarNoteBasename(activeNoteId) + const isWeekly = isValidWeekString(basename) + + const primaryText = isWeekly ? formatWeek(basename) : formatDate(basename) + const secondaryText = isWeekly ? formatWeekDistance(basename) : formatDateDistance(basename) const today = startOfToday() - const todayString = toDateString(today) - const thisWeekString = toWeekString(today) + const todayNoteId = buildId(toDateString(today)) + const thisWeekNoteId = buildId(toWeekString(today)) const navigateByInterval = React.useCallback( (direction: "previous" | "next") => { - const date = parseISO(activeNoteId) + const date = parseISO(basename) const increment = direction === "next" ? 1 : -1 - const target = isWeekly + const targetBasename = isWeekly ? toWeekString(addWeeks(date, increment)) : toDateString(addDays(date, increment)) navigate({ to: "/notes/$", - params: { _splat: target }, + params: { _splat: buildId(targetBasename) }, search: { mode: searchParams.mode ?? "read", query: undefined, @@ -51,11 +55,11 @@ export function CalendarHeader({ activeNoteId }: CalendarHeaderProps) { }, }) }, - [isWeekly, activeNoteId, navigate, searchParams.mode, searchParams.view], + [isWeekly, basename, navigate, searchParams.mode, searchParams.view, buildId], ) const navigateToCurrentPeriod = React.useCallback(() => { - const target = isWeekly ? thisWeekString : todayString + const target = isWeekly ? thisWeekNoteId : todayNoteId navigate({ to: "/notes/$", params: { _splat: target }, @@ -65,7 +69,7 @@ export function CalendarHeader({ activeNoteId }: CalendarHeaderProps) { view: searchParams.view === "list" ? "list" : "grid", }, }) - }, [isWeekly, thisWeekString, todayString, navigate, searchParams.mode, searchParams.view]) + }, [isWeekly, thisWeekNoteId, todayNoteId, navigate, searchParams.mode, searchParams.view]) return (
@@ -78,7 +82,7 @@ export function CalendarHeader({ activeNoteId }: CalendarHeaderProps) {
diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index 957ec1ab..90cccb93 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -15,8 +15,10 @@ import { useAtom } from "jotai" import React from "react" import { Link, useSearch } from "@tanstack/react-router" import { calendarLayoutAtom } from "../global-state" +import { useBuildCalendarNoteId } from "../hooks/config" import { useBacklinksForId, useNoteById } from "../hooks/note" import { Note } from "../schema" +import { getCalendarNoteBasename } from "../utils/config" import { cx } from "../utils/cx" import { DAY_NAMES, @@ -42,7 +44,10 @@ export function Calendar({ activeNoteId: string className?: string }) { - const date = parseISO(activeNoteId) + const buildId = useBuildCalendarNoteId() + // Extract the date/week part from the noteId (handles paths like "journal/2025-01-26") + const activeBasename = getCalendarNoteBasename(activeNoteId) + const date = parseISO(activeBasename) const [layout, setLayout] = useAtom(calendarLayoutAtom) // Local state for the displayed date anchor (independent of activeNoteId) @@ -164,14 +169,14 @@ export function Calendar({
{daysOfWeek.map((day) => ( ))}
@@ -196,11 +201,13 @@ function CalendarWeek({ startOfWeek: Date isActive?: boolean }) { + const buildId = useBuildCalendarNoteId() const weekString = toWeekString(startOfWeek) + const weekNoteId = buildId(weekString) const weekNumber = getISOWeek(startOfWeek) const label = formatWeek(weekString) - const existingNote = useNoteById(weekString) - const backlinks = useBacklinksForId(weekString) + const existingNote = useNoteById(weekNoteId) + const backlinks = useBacklinksForId(weekNoteId) const hasNotes = Boolean(existingNote) || backlinks.length > 0 const anchorRef = React.useContext(CalendarContainerContext) @@ -208,7 +215,7 @@ function CalendarWeek({ const note: Note = React.useMemo(() => { if (existingNote) return existingNote return { - id: weekString, + id: weekNoteId, content: "", type: "weekly", displayName: formatWeek(weekString), @@ -224,12 +231,12 @@ function CalendarWeek({ tasks: [], backlinks, } - }, [existingNote, weekString, backlinks]) + }, [existingNote, weekNoteId, weekString, backlinks]) return ( 0 const dayName = DAY_NAMES[date.getDay()] const monthName = MONTH_NAMES[date.getMonth()] @@ -259,7 +268,7 @@ function CalendarDate({ date, isActive = false }: { date: Date; isActive?: boole const note: Note = React.useMemo(() => { if (existingNote) return existingNote return { - id: dateString, + id: dateNoteId, content: "", type: "daily", displayName: formatDate(dateString), @@ -275,12 +284,12 @@ function CalendarDate({ date, isActive = false }: { date: Date; isActive?: boole tasks: [], backlinks, } - }, [existingNote, dateString, backlinks]) + }, [existingNote, dateNoteId, dateString, backlinks]) return ( 0 const daysOfWeek = React.useMemo(() => { @@ -450,14 +461,14 @@ function MonthWeekRow({ }, [mondayOfWeek]) const searchParams = useSearch({ strict: false }) - const isWeekActive = weekString === activeNoteId + const isWeekActive = weekNoteId === activeNoteId const anchorRef = React.useContext(CalendarContainerContext) // Create note object for hover card (fallback if note doesn't exist) const note: Note = React.useMemo(() => { if (existingNote) return existingNote return { - id: weekString, + id: weekNoteId, content: "", type: "weekly", displayName: formatWeek(weekString), @@ -473,12 +484,12 @@ function MonthWeekRow({ tasks: [], backlinks, } - }, [existingNote, weekString, backlinks]) + }, [existingNote, weekNoteId, weekString, backlinks]) const weekLink = ( ))}
@@ -541,15 +552,18 @@ function MonthWeekRow({ function MonthDateCell({ date, isOutsideMonth = false, - isActive = false, + activeNoteId, }: { date: Date isOutsideMonth?: boolean - isActive?: boolean + activeNoteId: string }) { + const buildId = useBuildCalendarNoteId() const dateString = toDateString(date) - const existingNote = useNoteById(dateString) - const backlinks = useBacklinksForId(dateString) + const dateNoteId = buildId(dateString) + const isActive = dateNoteId === activeNoteId + const existingNote = useNoteById(dateNoteId) + const backlinks = useBacklinksForId(dateNoteId) const hasNotes = Boolean(existingNote) || backlinks.length > 0 const dayName = DAY_NAMES[date.getDay()] const monthName = MONTH_NAMES[date.getMonth()] @@ -564,7 +578,7 @@ function MonthDateCell({ const note: Note = React.useMemo(() => { if (existingNote) return existingNote return { - id: dateString, + id: dateNoteId, content: "", type: "daily", displayName: formatDate(dateString), @@ -580,12 +594,12 @@ function MonthDateCell({ tasks: [], backlinks, } - }, [existingNote, dateString, backlinks]) + }, [existingNote, dateNoteId, dateString, backlinks]) const link = ( notes.has(toDateString(new Date()))) +// Check if daily note exists, considering calendar notes directory +const hasDailyNoteAtom = atom((get) => { + const notes = get(notesAtom) + const calendarNotesDir = get(calendarNotesDirAtom) + const todayId = buildCalendarNoteId(toDateString(new Date()), calendarNotesDir) + return notes.has(todayId) +}) export function CommandMenu() { const navigate = useNavigate() @@ -41,6 +56,7 @@ export function CommandMenu() { const saveNote = useSaveNote() const pinnedNotes = useAtomValue(pinnedNotesAtom) const getHasDailyNote = useAtomCallback(useCallback((get) => get(hasDailyNoteAtom), [])) + const buildCalendarNoteId = useBuildCalendarNoteId() const [isOpen, setIsOpen] = useAtom(isCommandMenuOpenAtom) // Get the current note if we're on a note page. @@ -115,7 +131,7 @@ export function CommandMenu() { navigate({ to: "/notes/$", params: { - _splat: toDateString(new Date()), + _splat: buildCalendarNoteId(toDateString(new Date())), }, search: { mode: getHasDailyNote() ? "read" : "write", @@ -326,7 +342,7 @@ export function CommandMenu() { navigate({ to: "/notes/$", params: { - _splat: dateString, + _splat: buildCalendarNoteId(dateString), }, search: { mode: "read", diff --git a/src/components/date-link.tsx b/src/components/date-link.tsx index 191be69e..4c2cb79b 100644 --- a/src/components/date-link.tsx +++ b/src/components/date-link.tsx @@ -2,28 +2,33 @@ import { Link } from "@tanstack/react-router" import { useMemo } from "react" import { useBacklinksForId, useNoteById } from "../hooks/note" import { Note } from "../schema" +import { getCalendarNoteBasename } from "../utils/config" import { cx } from "../utils/cx" import { formatDate } from "../utils/date" import { NoteHoverCard } from "./note-hover-card" type DateLinkProps = { - date: string + /** Full note ID (e.g., "2025-01-26" or "journal/2025-01-26") */ + noteId: string text?: string className?: string } -export function DateLink({ date, text, className }: DateLinkProps) { - const existingNote = useNoteById(date) - const backlinks = useBacklinksForId(date) +export function DateLink({ noteId, text, className }: DateLinkProps) { + const existingNote = useNoteById(noteId) + const backlinks = useBacklinksForId(noteId) + + // Extract the date part for formatting + const dateBasename = getCalendarNoteBasename(noteId) // Create a minimal note object if no note exists const note: Note = useMemo(() => { if (existingNote) return existingNote return { - id: date, + id: noteId, content: "", type: "daily", - displayName: formatDate(date), + displayName: formatDate(dateBasename), frontmatter: {}, title: "", url: null, @@ -36,15 +41,15 @@ export function DateLink({ date, text, className }: DateLinkProps) { tasks: [], backlinks, } - }, [existingNote, date, backlinks]) + }, [existingNote, noteId, dateBasename, backlinks]) - const linkText = text || formatDate(date) + const linkText = text || formatDate(dateBasename) const link = ( { const startOfWeek = parseISO(week) const endOfWeek = addDays(startOfWeek, 6) - return eachDayOfInterval({ start: startOfWeek, end: endOfWeek }).map(toDateString) - }, [week]) + return eachDayOfInterval({ start: startOfWeek, end: endOfWeek }).map((day) => ({ + basename: toDateString(day), + noteId: buildId(toDateString(day)), + })) + }, [week, buildId]) return (
{daysOfWeek.map((day) => ( - + ))}
) } -function Day({ date }: { date: string }) { - const note = useNoteById(date) +function Day({ basename, noteId }: { basename: string; noteId: string }) { + const note = useNoteById(noteId) if (!note) { // Placeholder return (
- {formatDate(date)} + {formatDate(basename)} - {formatDateDistance(date)} + {formatDateDistance(basename)}
) } - return + return } diff --git a/src/components/nav-items.tsx b/src/components/nav-items.tsx index 92d116e9..0ec2fc3e 100644 --- a/src/components/nav-items.tsx +++ b/src/components/nav-items.tsx @@ -4,9 +4,16 @@ import { selectAtom } from "jotai/utils" import { ComponentPropsWithoutRef, createContext, useContext } from "react" import { useNetworkState } from "react-use" import { useRegisterSW } from "virtual:pwa-register/react" -import { globalStateMachineAtom, notesAtom, pinnedNotesAtom } from "../global-state" +import { + calendarNotesDirAtom, + globalStateMachineAtom, + notesAtom, + pinnedNotesAtom, +} from "../global-state" +import { useBuildCalendarNoteId, useIsCalendarNoteId } from "../hooks/config" +import { buildCalendarNoteId } from "../utils/config" import { cx } from "../utils/cx" -import { isValidDateString, isValidWeekString, toDateString } from "../utils/date" +import { toDateString } from "../utils/date" import { CheatsheetDialog } from "./cheatsheet-dialog" import { Dialog } from "./dialog" import { @@ -25,7 +32,12 @@ import { import { NoteFavicon } from "./note-favicon" import { SyncStatusIcon, useSyncStatusText } from "./sync-status" -const hasDailyNoteAtom = selectAtom(notesAtom, (notes) => notes.has(toDateString(new Date()))) +// Check if today's daily note exists (accounting for calendar notes directory) +const hasDailyNoteAtomForDir = (calendarNotesDir: string) => + selectAtom(notesAtom, (notes) => { + const todayId = buildCalendarNoteId(toDateString(new Date()), calendarNotesDir) + return notes.has(todayId) + }) const SizeContext = createContext<"medium" | "large">("medium") @@ -37,18 +49,22 @@ export function NavItems({ onNavigate?: () => void }) { const pinnedNotes = useAtomValue(pinnedNotesAtom) + const calendarNotesDir = useAtomValue(calendarNotesDirAtom) + const hasDailyNoteAtom = hasDailyNoteAtomForDir(calendarNotesDir) const hasDailyNote = useAtomValue(hasDailyNoteAtom) const syncText = useSyncStatusText() const send = useSetAtom(globalStateMachineAtom) const { online } = useNetworkState() const { pathname } = useLocation() + const buildId = useBuildCalendarNoteId() const today = new Date() - const todayString = toDateString(today) + const todayNoteId = buildId(toDateString(today)) + const isCalendarNote = useIsCalendarNoteId() // Calendar link is active when viewing any daily or weekly note - const noteId = pathname.startsWith("/notes/") ? pathname.slice(7) : "" - const isCalendarActive = isValidDateString(noteId) || isValidWeekString(noteId) + const noteId = pathname.startsWith("/notes/") ? decodeURIComponent(pathname.slice(7)) : "" + const isCalendarActive = isCalendarNote(noteId) // Reference: https://vite-pwa-org.netlify.app/frameworks/react.html#prompt-for-update const { @@ -92,7 +108,7 @@ export function NavItems({
  • ( }, []) // Completions + const dateCompletion = useDateCompletion() const noteCompletion = useNoteCompletion() const tagSyntaxCompletion = useTagSyntaxCompletion() // #tag const tagPropertyCompletion = useTagPropertyCompletion() // tags: [tag] @@ -194,6 +196,7 @@ export const NoteEditor = React.forwardRef( onPaste, // TODO onEnter, vimMode, + dateCompletion, noteCompletion, tagPropertyCompletion, tagSyntaxCompletion, @@ -259,50 +262,60 @@ export const NoteEditor = React.forwardRef( }, ) -function dateCompletion(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/\[\[[^\]|^|]*/) +function useDateCompletion() { + const buildCalendarNoteId = useBuildCalendarNoteId() - if (!word) { - return null - } + const dateCompletion = React.useCallback( + (context: CompletionContext): CompletionResult | null => { + const word = context.matchBefore(/\[\[[^\]|^|]*/) - // "[[" -> "" - const query = word.text.replace(/^\[\[/, "") + if (!word) { + return null + } - if (!query) { - return null - } + // "[[" -> "" + const query = word.text.replace(/^\[\[/, "") - const date = parseDate(query) + if (!query) { + return null + } - if (!date) { - return null - } + const date = parseDate(query) - const year = String(date.getFullYear()).padStart(4, "0") - const month = String(date.getMonth() + 1).padStart(2, "0") - const day = String(date.getDate()).padStart(2, "0") - const dateString = `${year}-${month}-${day}` + if (!date) { + return null + } - return { - from: word.from, - options: [ - { - label: formatDate(dateString), - detail: formatDateDistance(dateString), - apply: (view, completion, from, to) => { - const text = `[[${dateString}]]` + const year = String(date.getFullYear()).padStart(4, "0") + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const dateString = `${year}-${month}-${day}` + const noteId = buildCalendarNoteId(dateString) - const hasClosingBrackets = view.state.sliceDoc(to, to + 2) === "]]" - view.dispatch({ - changes: { from, to: hasClosingBrackets ? to + 2 : to, insert: text }, - selection: { anchor: from + text.length }, - }) - }, - }, - ], - filter: false, - } + return { + from: word.from, + options: [ + { + label: formatDate(dateString), + detail: formatDateDistance(dateString), + apply: (view, completion, from, to) => { + const text = `[[${noteId}]]` + + const hasClosingBrackets = view.state.sliceDoc(to, to + 2) === "]]" + view.dispatch({ + changes: { from, to: hasClosingBrackets ? to + 2 : to, insert: text }, + selection: { anchor: from + text.length }, + }) + }, + }, + ], + filter: false, + } + }, + [buildCalendarNoteId], + ) + + return dateCompletion } /** diff --git a/src/components/note-link.tsx b/src/components/note-link.tsx index 5be4b813..88117e81 100644 --- a/src/components/note-link.tsx +++ b/src/components/note-link.tsx @@ -1,6 +1,8 @@ import { Link } from "@tanstack/react-router" +import { useIsCalendarNoteId } from "../hooks/config" import { useNoteById } from "../hooks/note" -import { isValidDateString, isValidWeekString } from "../utils/date" +import { getCalendarNoteBasename } from "../utils/config" +import { isValidWeekString } from "../utils/date" import { DateLink } from "./date-link" import { NoteHoverCard } from "./note-hover-card" import { WeekLink } from "./week-link" @@ -21,13 +23,15 @@ export function NoteLink({ hoverCardAlignOffset, }: NoteLinkProps) { const note = useNoteById(id) + const isCalendarNote = useIsCalendarNoteId() - if (isValidDateString(id)) { - return - } - - if (isValidWeekString(id)) { - return + // Check if this is a calendar note (e.g., "2025-01-26" or "journal/2025-01-26") + if (isCalendarNote(id)) { + const basename = getCalendarNoteBasename(id) + if (isValidWeekString(basename)) { + return + } + return } return ( diff --git a/src/components/note-preview.tsx b/src/components/note-preview.tsx index f004cce2..55b24156 100644 --- a/src/components/note-preview.tsx +++ b/src/components/note-preview.tsx @@ -2,7 +2,9 @@ import { useMatch } from "@tanstack/react-router" import { useAtomValue } from "jotai" import { useMemo } from "react" import { defaultFontAtom, githubRepoAtom } from "../global-state" +import { useIsCalendarNoteId } from "../hooks/config" import { Note, fontSchema } from "../schema" +import { getCalendarNoteBasename } from "../utils/config" import { cx } from "../utils/cx" import { formatDate, @@ -30,6 +32,7 @@ export function NotePreview({ note, className, hideProperties }: NotePreviewProp const highlightedHrefs = useLinkHighlight() const defaultFont = useAtomValue(defaultFontAtom) const githubRepo = useAtomValue(githubRepoAtom) + const isCalendarNoteId = useIsCalendarNoteId() // Prefer a local draft if it exists (unsaved changes) const { resolvedContent, isDraft } = useMemo(() => { @@ -66,8 +69,13 @@ export function NotePreview({ note, className, hideProperties }: NotePreviewProp // Compute birthday label const birthdayLabel = useMemo(() => { - // Only show when viewing a daily note - if (!currentNoteId || !isValidDateString(currentNoteId)) { + // Only show when viewing a daily note (must be in configured calendar directory) + if (!currentNoteId || !isCalendarNoteId(currentNoteId)) { + return null + } + const currentNoteBasename = getCalendarNoteBasename(currentNoteId) + // Must be a daily note (date pattern), not a weekly note + if (!isValidDateString(currentNoteBasename)) { return null } @@ -104,7 +112,7 @@ export function NotePreview({ note, className, hideProperties }: NotePreviewProp } // Extract month and day from current daily note - const [currentYear, currentMonth, currentDay] = currentNoteId.split("-").map(Number) + const [currentYear, currentMonth, currentDay] = currentNoteBasename.split("-").map(Number) // Check if month/day matches if (birthMonth !== currentMonth || birthDay !== currentDay) { @@ -120,7 +128,7 @@ export function NotePreview({ note, className, hideProperties }: NotePreviewProp } return "Birthday" - }, [currentNoteId, resolvedFrontmatter?.birthday]) + }, [currentNoteId, isCalendarNoteId, resolvedFrontmatter?.birthday]) return (
    - {note.type === "daily" ? formatDate(note.id) : note.displayName} + {note.type === "daily" + ? formatDate(getCalendarNoteBasename(note.id)) + : note.displayName} - {note.type === "daily" ? formatDateDistance(note.id) : formatWeekDistance(note.id)} + {note.type === "daily" + ? formatDateDistance(getCalendarNoteBasename(note.id)) + : formatWeekDistance(getCalendarNoteBasename(note.id))}
    ) : null} diff --git a/src/components/property-value.tsx b/src/components/property-value.tsx index e9e27aca..dbf36e15 100644 --- a/src/components/property-value.tsx +++ b/src/components/property-value.tsx @@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router" import { isToday } from "date-fns" import { useCallback, useRef, useState } from "react" import { z } from "zod" +import { useBuildCalendarNoteId } from "../hooks/config" import { formatDate, formatDateDistance, @@ -23,6 +24,7 @@ type PropertyValueProps = { } export function PropertyValue({ property: [key, value], onChange }: PropertyValueProps) { + const buildCalendarNoteId = useBuildCalendarNoteId() // Special keys switch (key) { case "isbn": @@ -131,7 +133,7 @@ export function PropertyValue({ property: [key, value], onChange }: PropertyValu const dateString = toDateStringUtc(value) return (
    - +
    ) } diff --git a/src/components/week-link.tsx b/src/components/week-link.tsx index dca3b017..39853d24 100644 --- a/src/components/week-link.tsx +++ b/src/components/week-link.tsx @@ -2,28 +2,33 @@ import { Link } from "@tanstack/react-router" import { useMemo } from "react" import { useBacklinksForId, useNoteById } from "../hooks/note" import { Note } from "../schema" +import { getCalendarNoteBasename } from "../utils/config" import { cx } from "../utils/cx" import { formatWeek } from "../utils/date" import { NoteHoverCard } from "./note-hover-card" export type WeekLinkProps = { - week: string + /** Full note ID (e.g., "2025-W04" or "journal/2025-W04") */ + noteId: string text?: string className?: string } -export function WeekLink({ week, text, className }: WeekLinkProps) { - const existingNote = useNoteById(week) - const backlinks = useBacklinksForId(week) +export function WeekLink({ noteId, text, className }: WeekLinkProps) { + const existingNote = useNoteById(noteId) + const backlinks = useBacklinksForId(noteId) + + // Extract the week part for formatting + const weekBasename = getCalendarNoteBasename(noteId) // Create a minimal note object if no note exists const note: Note = useMemo(() => { if (existingNote) return existingNote return { - id: week, + id: noteId, content: "", type: "weekly", - displayName: formatWeek(week), + displayName: formatWeek(weekBasename), frontmatter: {}, title: "", url: null, @@ -36,13 +41,13 @@ export function WeekLink({ week, text, className }: WeekLinkProps) { tasks: [], backlinks, } - }, [existingNote, week, backlinks]) + }, [existingNote, noteId, weekBasename, backlinks]) const link = ( - {text || formatWeek(week)} + {text || formatWeek(weekBasename)} ) } diff --git a/src/global-state.ts b/src/global-state.ts index 05bf6c53..0c5f3bcf 100644 --- a/src/global-state.ts +++ b/src/global-state.ts @@ -15,6 +15,14 @@ import { githubUserSchema, templateSchema, } from "./schema" +import { + Config, + CONFIG_FILE_PATH, + DEFAULT_CONFIG, + normalizeDirectoryPath, + parseConfigFromJson, + serializeConfig, +} from "./utils/config" import { fs, fsWipe } from "./utils/fs" import { REPO_DIR, @@ -38,6 +46,7 @@ import { startTimer } from "./utils/timer" const GITHUB_USER_STORAGE_KEY = "github_user" as const const MARKDOWN_FILES_STORAGE_KEY = "markdown_files" as const +const CONFIG_STORAGE_KEY = "lumen_config" as const type Context = { githubUser: GitHubUser | null @@ -461,6 +470,8 @@ function createGlobalStateMachine() { githubUser: (_, event) => { switch (event.type) { case "SIGN_IN": + // Save to localStorage when signing in directly (e.g., with PAT) + localStorage.setItem(GITHUB_USER_STORAGE_KEY, JSON.stringify(event.githubUser)) return event.githubUser case "done.invoke.global.resolvingUser:invocation[0]": return event.data.githubUser @@ -626,6 +637,67 @@ export const isSignedOutAtom = selectAtom(globalStateMachineAtom, (state) => state.matches("signedOut"), ) +// ----------------------------------------------------------------------------- +// Config +// ----------------------------------------------------------------------------- + +/** Get cached config from localStorage */ +function getConfigFromLocalStorage(): Config { + try { + const stored = localStorage.getItem(CONFIG_STORAGE_KEY) + if (stored) { + return parseConfigFromJson(stored) + } + } catch { + // Ignore errors + } + return DEFAULT_CONFIG +} + +/** Save config to localStorage */ +function setConfigToLocalStorage(config: Config) { + localStorage.setItem(CONFIG_STORAGE_KEY, serializeConfig(config)) +} + +/** Primitive atom to hold the config state */ +const configPrimitiveAtom = atom(getConfigFromLocalStorage()) + +/** Read-only atom for consuming the config */ +export const configAtom = atom((get) => get(configPrimitiveAtom)) + +/** Writable atom for updating the config */ +export const setConfigAtom = atom(null, (get, set, config: Config) => { + set(configPrimitiveAtom, config) + setConfigToLocalStorage(config) +}) + +/** Helper atom for the calendar notes directory (normalized) */ +export const calendarNotesDirAtom = atom((get) => { + const config = get(configAtom) + return normalizeDirectoryPath(config.calendarNotesDir) +}) + +/** Function to read config from filesystem and update the atom */ +export async function loadConfigFromFs(): Promise { + try { + const configPath = `${REPO_DIR}/${CONFIG_FILE_PATH}` + const content = await fs.promises.readFile(configPath, "utf8") + // fs.promises.readFile can return string or Uint8Array + const contentStr = typeof content === "string" ? content : new TextDecoder().decode(content) + return parseConfigFromJson(contentStr) + } catch { + // Config file doesn't exist, return default + return DEFAULT_CONFIG + } +} + +/** Atom to trigger config loading from filesystem */ +export const loadConfigAtom = atom(null, async (get, set) => { + const config = await loadConfigFromFs() + set(configPrimitiveAtom, config) + setConfigToLocalStorage(config) +}) + // ----------------------------------------------------------------------------- // GitHub // ----------------------------------------------------------------------------- @@ -646,13 +718,16 @@ export const githubRepoAtom = selectAtom( export const notesAtom = atom((get) => { const markdownFiles = get(markdownFilesAtom) + const config = get(configAtom) + const calendarNotesDir = normalizeDirectoryPath(config.calendarNotesDir) const notes: Map = new Map() // Parse notes for (const filepath in markdownFiles) { + // Note ID is just the filepath without .md extension const id = filepath.replace(/\.md$/, "") const content = markdownFiles[filepath] - notes.set(id, parseNote(id, content)) + notes.set(id, parseNote(id, content, calendarNotesDir)) } // Derive backlinks diff --git a/src/hooks/config.ts b/src/hooks/config.ts new file mode 100644 index 00000000..de9e9a96 --- /dev/null +++ b/src/hooks/config.ts @@ -0,0 +1,89 @@ +import { useAtomValue, useSetAtom } from "jotai" +import React from "react" +import { + calendarNotesDirAtom, + configAtom, + globalStateMachineAtom, + isRepoClonedAtom, + loadConfigAtom, + setConfigAtom, +} from "../global-state" +import { + Config, + CONFIG_FILE_PATH, + normalizeDirectoryPath, + serializeConfig, + buildCalendarNoteId, + isCalendarNoteId, +} from "../utils/config" + +export function useConfig() { + return useAtomValue(configAtom) +} + +export function useCalendarNotesDirectory() { + return useAtomValue(calendarNotesDirAtom) +} + +/** + * Returns a function to build calendar note IDs with the configured directory. + * e.g., with directory "journal": buildId("2026-01-26") -> "journal/2026-01-26" + */ +export function useBuildCalendarNoteId() { + const calendarNotesDir = useAtomValue(calendarNotesDirAtom) + return React.useCallback( + (dateOrWeek: string) => buildCalendarNoteId(dateOrWeek, calendarNotesDir), + [calendarNotesDir], + ) +} + +/** + * Returns a function to check if a note ID is a calendar note (with directory config). + */ +export function useIsCalendarNoteId() { + const calendarNotesDir = useAtomValue(calendarNotesDirAtom) + return React.useCallback( + (noteId: string) => isCalendarNoteId(noteId, calendarNotesDir), + [calendarNotesDir], + ) +} + +/** Load config from filesystem when repo is cloned */ +export function useLoadConfigOnMount() { + const isRepoCloned = useAtomValue(isRepoClonedAtom) + const loadConfig = useSetAtom(loadConfigAtom) + const hasLoadedRef = React.useRef(false) + + React.useEffect(() => { + if (isRepoCloned && !hasLoadedRef.current) { + hasLoadedRef.current = true + loadConfig() + } + }, [isRepoCloned, loadConfig]) +} + +export function useSaveConfig() { + const send = useSetAtom(globalStateMachineAtom) + const setConfig = useSetAtom(setConfigAtom) + + return React.useCallback( + (config: Config) => { + // Normalize the config before saving + const normalizedConfig: Config = { + ...config, + calendarNotesDir: normalizeDirectoryPath(config.calendarNotesDir) || undefined, + } + + // Update the config atom (and localStorage) + setConfig(normalizedConfig) + + // Write the config file to the repo + send({ + type: "WRITE_FILES", + markdownFiles: { [CONFIG_FILE_PATH]: serializeConfig(normalizedConfig) }, + commitMessage: "Update Lumen config", + }) + }, + [send, setConfig], + ) +} diff --git a/src/hooks/note.ts b/src/hooks/note.ts index c9a28d7e..b5a2da8c 100644 --- a/src/hooks/note.ts +++ b/src/hooks/note.ts @@ -3,6 +3,7 @@ import { selectAtom, useAtomCallback } from "jotai/utils" import React from "react" import { backlinksIndexAtom, + calendarNotesDirAtom, githubRepoAtom, githubUserAtom, globalStateMachineAtom, @@ -12,9 +13,9 @@ import { import { Note, NoteId } from "../schema" import { parseFrontmatter, updateFrontmatterValue } from "../utils/frontmatter" import { deleteGist, updateGist } from "../utils/gist" +import { isValidNoteId } from "../utils/note-id" import { parseNote } from "../utils/parse-note" import { updateWikilinks } from "../utils/update-wikilinks" -import { isValidNoteId } from "../utils/note-id" const EMPTY_BACKLINKS: NoteId[] = [] @@ -54,6 +55,7 @@ export function useSaveNote() { const send = useSetAtom(globalStateMachineAtom) const githubUser = useAtomValue(githubUserAtom) const githubRepo = useAtomValue(githubRepoAtom) + const calendarNotesDir = useAtomValue(calendarNotesDirAtom) const saveNote = React.useCallback( async ({ id, content }: Pick) => { @@ -63,9 +65,12 @@ export function useSaveNote() { properties: { updated_at: new Date() }, }) + // Filepath is just the note ID with .md extension + const filepath = `${id}.md` + send({ type: "WRITE_FILES", - markdownFiles: { [`${id}.md`]: contentWithTimestamp }, + markdownFiles: { [filepath]: contentWithTimestamp }, }) // If the note has a gist ID, update the gist @@ -73,13 +78,13 @@ export function useSaveNote() { if (typeof frontmatter.gist_id === "string" && githubUser && githubRepo) { await updateGist({ gistId: frontmatter.gist_id, - note: parseNote(id ?? "", contentWithTimestamp), + note: parseNote(id ?? "", contentWithTimestamp, calendarNotesDir), githubUser, githubRepo, }) } }, - [send, githubUser, githubRepo], + [send, githubUser, githubRepo, calendarNotesDir], ) return saveNote @@ -120,10 +125,14 @@ export function useRenameNote() { const updatedMarkdownFiles: Record = {} // Update wikilinks in all other notes - for (const [filepath, content] of Object.entries(markdownFiles)) { + for (const [filepath, fileContent] of Object.entries(markdownFiles)) { if (filepath === oldFilepath) continue - const newContent = updateWikilinks({ fileContent: content, oldId: oldName, newId: newName }) - if (newContent !== content) { + const newContent = updateWikilinks({ + fileContent, + oldId: oldName, + newId: newName, + }) + if (newContent !== fileContent) { updatedMarkdownFiles[filepath] = newContent } } @@ -173,7 +182,8 @@ export function useDeleteNote() { }) } - send({ type: "DELETE_FILE", filepath: `${id}.md` }) + const filepath = `${id}.md` + send({ type: "DELETE_FILE", filepath }) }, [send, githubUser, getNoteById], ) diff --git a/src/routes/_appRoot.notes_.$.tsx b/src/routes/_appRoot.notes_.$.tsx index 2e7963d6..6f286608 100644 --- a/src/routes/_appRoot.notes_.$.tsx +++ b/src/routes/_appRoot.notes_.$.tsx @@ -58,12 +58,14 @@ import { weeklyTemplateAtom, } from "../global-state" import { useAttachFile } from "../hooks/attach-file" +import { useCalendarNotesDirectory } from "../hooks/config" import { useDeleteNote, useNoteById, useRenameNote, useSaveNote } from "../hooks/note" import { useSearchNotes } from "../hooks/search-notes" import { useValueRef } from "../hooks/value-ref" import { Note, NoteId, Template, Width, fontSchema, widthSchema } from "../schema" +import { getCalendarNoteBasename, isCalendarNoteId } from "../utils/config" import { cx } from "../utils/cx" -import { formatDate, formatWeek, isValidDateString, isValidWeekString } from "../utils/date" +import { formatDate, formatWeek } from "../utils/date" import { updateFrontmatterValue } from "../utils/frontmatter" import { clearNoteDraft, getNoteDraft, setNoteDraft } from "../utils/note-draft" import { getInvalidNoteIdCharacters } from "../utils/note-id" @@ -133,12 +135,14 @@ function NotePage() { const dailyTemplate = useAtomValue(dailyTemplateAtom) const weeklyTemplate = useAtomValue(weeklyTemplateAtom) const defaultFont = useAtomValue(defaultFontAtom) + const calendarNotesDir = useCalendarNotesDirectory() const { online } = useNetworkState() // Note data const note = useNoteById(noteId) - const isDailyNote = isValidDateString(noteId ?? "") - const isWeeklyNote = isValidWeekString(noteId ?? "") + const noteBasename = getCalendarNoteBasename(noteId ?? "") + // Check if this note ID is a calendar note based on basename AND directory config + const isCalendarNote = isCalendarNoteId(noteId ?? "", calendarNotesDir) const searchNotes = useSearchNotes() const saveNote = useSaveNote() const backlinks = React.useMemo(() => { @@ -153,16 +157,16 @@ function NotePage() { note, defaultValue: defaultContent ? defaultContent - : isDailyNote && dailyTemplate - ? renderTemplate(dailyTemplate, { date: noteId ?? "" }) - : isWeeklyNote && weeklyTemplate - ? renderTemplate(weeklyTemplate, { week: noteId ?? "" }) + : isCalendarNote && noteBasename.includes("-W") && weeklyTemplate + ? renderTemplate(weeklyTemplate, { week: noteBasename }) + : isCalendarNote && !noteBasename.includes("-W") && dailyTemplate + ? renderTemplate(dailyTemplate, { date: noteBasename }) : "", }) const vimMode = useAtomValue(vimModeAtom) const parsedNote = React.useMemo( - () => parseNote(noteId ?? "", editorValue), - [noteId, editorValue], + () => parseNote(noteId ?? "", editorValue, calendarNotesDir), + [noteId, editorValue, calendarNotesDir], ) const [isDraggingFile, setIsDraggingFile] = React.useState(false) @@ -796,7 +800,7 @@ function NotePage() { resolvedWidth === "fixed" && "mx-auto max-w-[700px]", )} > - {isDailyNote || isWeeklyNote ? ( + {parsedNote?.type === "daily" || parsedNote?.type === "weekly" ? (
    @@ -819,11 +823,12 @@ function NotePage() { { // When printing a daily or weekly note without a title, // insert the date or week number as the title - (isDailyNote || isWeeklyNote) && !note?.title ? ( + (parsedNote?.type === "daily" || parsedNote?.type === "weekly") && + !note?.title ? (

    - {isDailyNote - ? formatDate(noteId ?? "", { alwaysIncludeYear: true }) - : formatWeek(noteId ?? "")} + {parsedNote?.type === "daily" + ? formatDate(noteBasename, { alwaysIncludeYear: true }) + : formatWeek(noteBasename)}

    ) : null } @@ -878,10 +883,10 @@ function NotePage() { minHeight={160} />
    - {isWeeklyNote ? ( + {parsedNote?.type === "weekly" ? (
    Days - +
    ) : null} {backlinks.size > 0 ? ( diff --git a/src/routes/_appRoot.settings.tsx b/src/routes/_appRoot.settings.tsx index e4cb1d6c..5fb01077 100644 --- a/src/routes/_appRoot.settings.tsx +++ b/src/routes/_appRoot.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" import { useAtom, useAtomValue } from "jotai" -import { useState } from "react" +import { useRef, useState } from "react" import { useNetworkState } from "react-use" import { Button } from "../components/button" import { useSignOut } from "../components/github-auth" @@ -22,6 +22,7 @@ import { vimModeAtom, voiceAssistantEnabledAtom, } from "../global-state" +import { useCalendarNotesDirectory, useConfig, useSaveConfig } from "../hooks/config" import { cx } from "../utils/cx" export const Route = createFileRoute("/_appRoot/settings")({ @@ -37,6 +38,7 @@ function RouteComponent() {
    + @@ -155,6 +157,90 @@ function GitHubSection() { ) } +function NotesSection() { + const isRepoCloned = useAtomValue(isRepoClonedAtom) + const config = useConfig() + const calendarNotesDir = useCalendarNotesDirectory() + const saveConfig = useSaveConfig() + const [isEditing, setIsEditing] = useState(false) + const [inputValue, setInputValue] = useState(calendarNotesDir) + const inputRef = useRef(null) + + // Don't show this section if no repo is cloned + if (!isRepoCloned) { + return null + } + + const handleSave = () => { + saveConfig({ + ...config, + calendarNotesDir: inputValue.trim() || undefined, + }) + setIsEditing(false) + } + + const handleCancel = () => { + setInputValue(calendarNotesDir) + setIsEditing(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave() + } else if (e.key === "Escape") { + handleCancel() + } + } + + return ( + +
    + Calendar notes directory + {isEditing ? ( +
    + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., journal or daily" + autoFocus + /> + + +
    + ) : ( +
    + + {calendarNotesDir ? ( + + {calendarNotesDir}/ + + ) : ( + Repository root + )} + + +
    + )} + + Directory where daily and weekly notes are stored (e.g., 2025-01-26.md). Leave empty to + use the repository root. + +
    +
    + ) +} + function AppearanceSection() { const [epaper, setEpaper] = useAtom(epaperAtom) diff --git a/src/routes/_appRoot.tsx b/src/routes/_appRoot.tsx index eb936f4d..f5a71552 100644 --- a/src/routes/_appRoot.tsx +++ b/src/routes/_appRoot.tsx @@ -20,6 +20,7 @@ import { tagsAtom, templatesAtom, } from "../global-state" +import { useLoadConfigOnMount } from "../hooks/config" import { useSearchNotes } from "../hooks/search-notes" import { useValueRef } from "../hooks/value-ref" import { generateNoteId } from "../utils/note-id" @@ -53,6 +54,9 @@ function RouteComponent() { const { online } = useNetworkState() const rootRef = React.useRef(null) + // Load config from filesystem when repo is cloned + useLoadConfigOnMount() + // Sync when the app becomes visible again useEvent("visibilitychange", () => { if (document.visibilityState === "visible" && online) { diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts new file mode 100644 index 00000000..9d8d182c --- /dev/null +++ b/src/utils/config.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest" +import { + DEFAULT_CONFIG, + buildCalendarNoteId, + getCalendarNoteBasename, + isCalendarNoteId, + normalizeDirectoryPath, + parseConfigFromJson, + serializeConfig, +} from "./config" + +describe("parseConfigFromJson", () => { + it("parses valid config", () => { + const json = JSON.stringify({ calendarNotesDir: "journal" }) + expect(parseConfigFromJson(json)).toEqual({ calendarNotesDir: "journal" }) + }) + + it("returns default config for invalid JSON", () => { + expect(parseConfigFromJson("not valid json")).toEqual(DEFAULT_CONFIG) + }) + + it("returns default config for empty object", () => { + expect(parseConfigFromJson("{}")).toEqual({}) + }) + + it("ignores unknown properties", () => { + const json = JSON.stringify({ calendarNotesDir: "daily", unknownProp: "value" }) + expect(parseConfigFromJson(json)).toEqual({ calendarNotesDir: "daily" }) + }) +}) + +describe("serializeConfig", () => { + it("serializes config to JSON", () => { + const config = { calendarNotesDir: "journal" } + const json = serializeConfig(config) + expect(JSON.parse(json)).toEqual(config) + }) +}) + +describe("normalizeDirectoryPath", () => { + it("removes leading slashes", () => { + expect(normalizeDirectoryPath("/journal")).toBe("journal") + expect(normalizeDirectoryPath("//journal")).toBe("journal") + }) + + it("removes trailing slashes", () => { + expect(normalizeDirectoryPath("journal/")).toBe("journal") + expect(normalizeDirectoryPath("journal//")).toBe("journal") + }) + + it("removes both leading and trailing slashes", () => { + expect(normalizeDirectoryPath("/journal/")).toBe("journal") + }) + + it("handles nested paths", () => { + expect(normalizeDirectoryPath("/notes/daily/")).toBe("notes/daily") + }) + + it("returns empty string for undefined", () => { + expect(normalizeDirectoryPath(undefined)).toBe("") + }) + + it("returns empty string for empty string", () => { + expect(normalizeDirectoryPath("")).toBe("") + }) +}) + +describe("isCalendarNoteId", () => { + describe("without directory parameter (backward compatible)", () => { + it("returns true for valid date strings", () => { + expect(isCalendarNoteId("2025-01-26")).toBe(true) + expect(isCalendarNoteId("2000-12-31")).toBe(true) + }) + + it("returns true for valid week strings", () => { + expect(isCalendarNoteId("2025-W04")).toBe(true) + expect(isCalendarNoteId("2025-W52")).toBe(true) + }) + + it("returns true for calendar notes with directory prefix", () => { + expect(isCalendarNoteId("journal/2025-01-26")).toBe(true) + expect(isCalendarNoteId("notes/daily/2025-W04")).toBe(true) + }) + + it("returns false for regular note IDs", () => { + expect(isCalendarNoteId("my-note")).toBe(false) + expect(isCalendarNoteId("some/path/note")).toBe(false) + expect(isCalendarNoteId("1706313600000")).toBe(false) + }) + + it("returns false for invalid dates", () => { + expect(isCalendarNoteId("2025-13-01")).toBe(false) // Invalid month + expect(isCalendarNoteId("2025-1-26")).toBe(false) // Missing leading zero + }) + }) + + describe("with empty directory (root only)", () => { + it("returns true for date notes at root", () => { + expect(isCalendarNoteId("2025-01-26", "")).toBe(true) + expect(isCalendarNoteId("2025-W04", "")).toBe(true) + }) + + it("returns false for date notes in directories", () => { + expect(isCalendarNoteId("journal/2025-01-26", "")).toBe(false) + expect(isCalendarNoteId("notes/2025-W04", "")).toBe(false) + }) + }) + + describe("with configured directory", () => { + it("returns true for date notes in configured directory", () => { + expect(isCalendarNoteId("journal/2025-01-26", "journal")).toBe(true) + expect(isCalendarNoteId("journal/2025-W04", "journal")).toBe(true) + }) + + it("returns false for date notes at root when directory is configured", () => { + expect(isCalendarNoteId("2025-01-26", "journal")).toBe(false) + expect(isCalendarNoteId("2025-W04", "journal")).toBe(false) + }) + + it("returns false for date notes in wrong directory", () => { + expect(isCalendarNoteId("other/2025-01-26", "journal")).toBe(false) + expect(isCalendarNoteId("notes/daily/2025-W04", "journal")).toBe(false) + }) + + it("handles nested directory paths", () => { + expect(isCalendarNoteId("notes/daily/2025-01-26", "notes/daily")).toBe(true) + expect(isCalendarNoteId("notes/2025-01-26", "notes/daily")).toBe(false) + }) + }) +}) + +describe("getCalendarNoteBasename", () => { + it("returns the date/week part from a simple ID", () => { + expect(getCalendarNoteBasename("2025-01-26")).toBe("2025-01-26") + expect(getCalendarNoteBasename("2025-W04")).toBe("2025-W04") + }) + + it("returns the date/week part from a path ID", () => { + expect(getCalendarNoteBasename("journal/2025-01-26")).toBe("2025-01-26") + expect(getCalendarNoteBasename("notes/daily/2025-W04")).toBe("2025-W04") + }) +}) + +describe("buildCalendarNoteId", () => { + it("returns the date string when no directory is configured", () => { + expect(buildCalendarNoteId("2025-01-26", "")).toBe("2025-01-26") + expect(buildCalendarNoteId("2025-W04", "")).toBe("2025-W04") + }) + + it("prepends directory when configured", () => { + expect(buildCalendarNoteId("2025-01-26", "journal")).toBe("journal/2025-01-26") + expect(buildCalendarNoteId("2025-W04", "daily")).toBe("daily/2025-W04") + expect(buildCalendarNoteId("2025-01-26", "notes/calendar")).toBe("notes/calendar/2025-01-26") + }) +}) diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 00000000..43fb956a --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,99 @@ +import { z } from "zod" +import { isValidDateString, isValidWeekString } from "./date" + +// Schema for the config stored in .lumen/config.json +export const configSchema = z.object({ + // Directory for calendar notes (daily/weekly). Empty string or undefined means repo root. + calendarNotesDir: z.string().optional(), +}) + +export type Config = z.infer + +export const DEFAULT_CONFIG: Config = { + calendarNotesDir: undefined, +} + +export const CONFIG_FILE_PATH = ".lumen/config.json" + +/** Parse config from raw JSON string */ +export function parseConfigFromJson(json: string): Config { + try { + const parsed = JSON.parse(json) + const result = configSchema.safeParse(parsed) + if (result.success) { + return result.data + } + console.warn("Invalid config schema:", result.error) + return DEFAULT_CONFIG + } catch (error) { + console.warn("Failed to parse config JSON:", error) + return DEFAULT_CONFIG + } +} + +/** Serialize config to JSON string */ +export function serializeConfig(config: Config): string { + return JSON.stringify(config, null, 2) +} + +/** + * Normalize a directory path: + * - Remove leading/trailing slashes + * - Return empty string for root + */ +export function normalizeDirectoryPath(path: string | undefined): string { + if (!path) return "" + return path.replace(/^\/+|\/+$/g, "") +} + +/** + * Check if a note ID represents a calendar note (daily or weekly). + * Checks the basename (last path segment) for date/week patterns. + * + * When calendarNotesDir is provided: + * - If empty string: note must be at root (no path separator) + * - If non-empty: note must be inside that directory + * When calendarNotesDir is undefined, only checks the basename pattern. + */ +export function isCalendarNoteId(noteId: string, calendarNotesDir?: string): boolean { + const basename = noteId.split("/").pop() || noteId + const hasCalendarBasename = isValidDateString(basename) || isValidWeekString(basename) + + if (!hasCalendarBasename) { + return false + } + + // If no directory config provided, just check the basename + if (calendarNotesDir === undefined) { + return true + } + + // Check if the note is in the correct directory + if (calendarNotesDir === "") { + // No directory configured - note must be at root (no slashes) + return !noteId.includes("/") + } + + // Directory is configured - note must be inside it + const expectedPrefix = `${calendarNotesDir}/` + return noteId.startsWith(expectedPrefix) && noteId.slice(expectedPrefix.length) === basename +} + +/** + * Get the basename (date/week part) from a calendar note ID. + * e.g., "journal/2026-01-26" -> "2026-01-26" + */ +export function getCalendarNoteBasename(noteId: string): string { + return noteId.split("/").pop() || noteId +} + +/** + * Build a calendar note ID with the configured directory. + * e.g., ("2026-01-26", "journal") -> "journal/2026-01-26" + */ +export function buildCalendarNoteId(dateOrWeek: string, calendarNotesDir: string): string { + if (calendarNotesDir) { + return `${calendarNotesDir}/${dateOrWeek}` + } + return dateOrWeek +} diff --git a/src/utils/parse-note.test.ts b/src/utils/parse-note.test.ts index 025a7298..5cf13ddb 100644 --- a/src/utils/parse-note.test.ts +++ b/src/utils/parse-note.test.ts @@ -2,6 +2,53 @@ import { describe, expect, test } from "vitest" import { isNoteEmpty, parseNote } from "./parse-note" describe("parseNote", () => { + describe("note type detection with calendarNotesDir", () => { + test("detects daily note when in correct directory", () => { + const note = parseNote("journal/2026-01-26", "# Test", "journal") + expect(note.type).toBe("daily") + }) + + test("does NOT detect daily note when missing required prefix", () => { + const note = parseNote("2026-01-26", "# Test", "journal") + expect(note.type).toBe("note") + }) + + test("does NOT detect daily note when in wrong directory", () => { + const note = parseNote("other/2026-01-26", "# Test", "journal") + expect(note.type).toBe("note") + }) + + test("detects daily note at root when no directory configured", () => { + const note = parseNote("2026-01-26", "# Test", "") + expect(note.type).toBe("daily") + }) + + test("does NOT detect daily note in subdirectory when root expected", () => { + const note = parseNote("journal/2026-01-26", "# Test", "") + expect(note.type).toBe("note") + }) + + test("detects weekly note when in correct directory", () => { + const note = parseNote("journal/2026-W04", "# Test", "journal") + expect(note.type).toBe("weekly") + }) + + test("does NOT detect weekly note when missing required prefix", () => { + const note = parseNote("2026-W04", "# Test", "journal") + expect(note.type).toBe("note") + }) + + test("backward compatible: detects daily note when calendarNotesDir undefined", () => { + const note = parseNote("2026-01-26", "# Test") + expect(note.type).toBe("daily") + }) + + test("backward compatible: detects daily note in subdir when calendarNotesDir undefined", () => { + const note = parseNote("journal/2026-01-26", "# Test") + expect(note.type).toBe("daily") + }) + }) + test("stores task markdown, links, and tags", () => { const tasks = parseNote("1234", "- [ ] Review [[project-alpha]] plan #ops").tasks diff --git a/src/utils/parse-note.ts b/src/utils/parse-note.ts index 37d93289..4a689f49 100644 --- a/src/utils/parse-note.ts +++ b/src/utils/parse-note.ts @@ -11,6 +11,7 @@ import { priority, priorityFromMarkdown } from "../remark-plugins/priority" import { tag, tagFromMarkdown } from "../remark-plugins/tag" import { wikilink, wikilinkFromMarkdown } from "../remark-plugins/wikilink" import { Note, NoteId, NoteType, Task, Template, templateSchema } from "../schema" +import { getCalendarNoteBasename, isCalendarNoteId } from "./config" import { formatDate, formatWeek, @@ -23,13 +24,19 @@ import { removeLeadingEmoji } from "./emoji" import { hasVisibleFrontmatter, parseFrontmatter } from "./frontmatter" import { getTaskContent, getTaskDate, getTaskLinks, getTaskPriority, getTaskTags } from "./task" -/** Extracts metadata from markdown content to construct a Note object. */ +/** Extracts metadata from markdown content to construct a Note object. + * @param id - The note ID (filepath without .md extension) + * @param content - The markdown content of the note + * @param calendarNotesDir - Optional. The configured calendar notes directory. + * When provided, note type detection requires matching directory prefix. + * When undefined, only basename is checked (backward compatible). + */ export const parseNote = // We memoize this function because it's called a lot and it's expensive. // We're intentionally sacrificing memory usage for runtime performance. memoize(_parseNote) -function _parseNote(id: NoteId, content: string): Note { +function _parseNote(id: NoteId, content: string, calendarNotesDir?: string): Note { let type: NoteType = "note" let displayName = "" let title = "" @@ -190,12 +197,19 @@ function _parseNote(id: NoteId, content: string): Note { ) } + // Extract basename for date/week formatting (handles paths like "journal/2026-01-26") + const basename = getCalendarNoteBasename(id) + + // Check if the note is a valid calendar note (checks both basename and directory prefix) + const isCalendarNote = isCalendarNoteId(id, calendarNotesDir) + // Determine the type of the note - if (isValidDateString(id)) { + // Only mark as daily/weekly if it's in the correct calendar notes directory + if (isCalendarNote && isValidDateString(basename)) { type = "daily" // Add the daily note's date to its dates array - dates.add(id) - } else if (isValidWeekString(id)) { + dates.add(basename) + } else if (isCalendarNote && isValidWeekString(basename)) { type = "weekly" } else if (templateSchema.omit({ body: true }).safeParse(frontmatter.template).success) { type = "template" @@ -204,11 +218,11 @@ function _parseNote(id: NoteId, content: string): Note { switch (type) { case "daily": // Fallback to the formatted date if there's no title - displayName = title ? removeLeadingEmoji(title) : formatDate(id) + displayName = title ? removeLeadingEmoji(title) : formatDate(basename) break case "weekly": // Fallback to the formatted week if there's no title - displayName = title ? removeLeadingEmoji(title) : formatWeek(id) + displayName = title ? removeLeadingEmoji(title) : formatWeek(basename) break case "template": displayName = `${(frontmatter.template as Template).name} template`