diff --git a/app/_components/GlobalComponents/Modals/ChecklistModals/EditChecklistModal.tsx b/app/_components/GlobalComponents/Modals/ChecklistModals/EditChecklistModal.tsx
index 64c24adb3..7ab0ccbb1 100644
--- a/app/_components/GlobalComponents/Modals/ChecklistModals/EditChecklistModal.tsx
+++ b/app/_components/GlobalComponents/Modals/ChecklistModals/EditChecklistModal.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
-import { getListById, updateList } from "@/app/_server/actions/checklist";
+import { viewList, updateList } from "@/app/_server/actions/checklist";
import { Button } from "@/app/_components/GlobalComponents/Buttons/Button";
import { CategoryTreeSelector } from "@/app/_components/GlobalComponents/Dropdowns/CategoryTreeSelector";
import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal";
@@ -44,10 +44,7 @@ export const EditChecklistModal = ({
const fetchChecklist = async () => {
if (!user?.username || !initialChecklist.uuid) return;
- const fetchedChecklist = await getListById(
- initialChecklist.uuid,
- user.username,
- );
+ const fetchedChecklist = await viewList(initialChecklist.uuid);
if (!fetchedChecklist) {
setIsMissing(true);
diff --git a/app/_components/GlobalComponents/Modals/NotesModal/CreateNoteModal.tsx b/app/_components/GlobalComponents/Modals/NotesModal/CreateNoteModal.tsx
index 7992812b0..7f1701a9f 100644
--- a/app/_components/GlobalComponents/Modals/NotesModal/CreateNoteModal.tsx
+++ b/app/_components/GlobalComponents/Modals/NotesModal/CreateNoteModal.tsx
@@ -14,6 +14,7 @@ import { Toggle } from "@/app/_components/GlobalComponents/FormElements/Toggle";
import { PGPEncryptionModal } from "@/app/_components/GlobalComponents/Modals/EncryptionModals/PGPEncryptionModal";
import { XChaChaEncryptionModal } from "@/app/_components/GlobalComponents/Modals/EncryptionModals/XChaChaEncryptionModal";
import { useTranslations } from "next-intl";
+import { useToast } from "@/app/_providers/ToastProvider";
const ENCRYPTION_PLACEHOLDER_CONTENT = "Note placeholder";
@@ -32,6 +33,7 @@ export const CreateNoteModal = ({
}: CreateNoteModalProps) => {
const t = useTranslations();
const { user } = useAppMode();
+ const { showToast } = useToast();
const [title, setTitle] = useState("");
const [category, setCategory] = useState(initialCategory);
const [newCategory, setNewCategory] = useState("");
@@ -47,7 +49,16 @@ export const CreateNoteModal = ({
titleInputRef.current?.focus();
}, []);
- const _resolveCategoryPath = async (): Promise => {
+ const _complain = (message: string, error?: string) => {
+ console.warn("Note creation refused:", error || message);
+ showToast({
+ type: "error",
+ title: t("common.error"),
+ message: error || message,
+ });
+ };
+
+ const _resolveCategoryPath = async (): Promise => {
if (showNewCategory && newCategory.trim()) {
const newCatTrimmed = newCategory.trim();
const categoryFormData = new FormData();
@@ -56,7 +67,11 @@ export const CreateNoteModal = ({
if (category) {
categoryFormData.append("parent", category);
}
- await createCategory(categoryFormData);
+ const made = await createCategory(categoryFormData);
+ if (!made.success) {
+ _complain(t("notes.createCategoryFailed"), made.error);
+ return null;
+ }
return category ? `${category}/${newCatTrimmed}` : newCatTrimmed;
}
return category;
@@ -64,17 +79,22 @@ export const CreateNoteModal = ({
const _finalizeCreate = async (rawContent: string, isEncrypted = false) => {
const finalCategoryPath = await _resolveCategoryPath();
+ if (finalCategoryPath === null) return;
+
const formData = new FormData();
formData.append("title", title.trim());
formData.append("category", finalCategoryPath);
formData.append("rawContent", rawContent);
const result = await createNote(formData);
- if (result.success) {
- const doc = result.data && isEncrypted
- ? { ...result.data, encrypted: true }
- : result.data;
- onCreated(doc);
+ if (!result.success) {
+ _complain(t("notes.createNoteFailed"), result.error);
+ return;
}
+
+ const doc = result.data && isEncrypted
+ ? { ...result.data, encrypted: true }
+ : result.data;
+ onCreated(doc);
};
const handleSubmit = async (e: React.FormEvent) => {
diff --git a/app/_components/GlobalComponents/Modals/NotesModal/EditNoteModal.tsx b/app/_components/GlobalComponents/Modals/NotesModal/EditNoteModal.tsx
index 4d7ea2a58..7b119b1e7 100644
--- a/app/_components/GlobalComponents/Modals/NotesModal/EditNoteModal.tsx
+++ b/app/_components/GlobalComponents/Modals/NotesModal/EditNoteModal.tsx
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal";
import { Button } from "@/app/_components/GlobalComponents/Buttons/Button";
import { CategoryTreeSelector } from "@/app/_components/GlobalComponents/Dropdowns/CategoryTreeSelector";
-import { getNoteById, updateNote } from "@/app/_server/actions/note";
+import { viewNote, updateNote } from "@/app/_server/actions/note";
import { Note, Category } from "@/app/_types";
import { ARCHIVED_DIR_NAME } from "@/app/_consts/files";
import { itemHref } from "@/app/_utils/global-utils";
@@ -48,7 +48,7 @@ export const EditNoteModal = ({
const fetchNote = async () => {
if (!user?.username || !initialNote.uuid) return;
- const fetchedNote = await getNoteById(initialNote.uuid, user.username);
+ const fetchedNote = await viewNote(initialNote.uuid);
if (!fetchedNote) {
setIsMissing(true);
diff --git a/app/_consts/identity.ts b/app/_consts/identity.ts
index a0c14893c..e1cb449c9 100644
--- a/app/_consts/identity.ts
+++ b/app/_consts/identity.ts
@@ -2,3 +2,5 @@ export const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const isUuid = (value: string): boolean => UUID_REGEX.test(value);
+
+export const PATH_UUID_NAMESPACE = "3170b6d3-f27c-4d18-b640-d52e20c2790a";
diff --git a/app/_hooks/kanban/useKanban.ts b/app/_hooks/kanban/useKanban.ts
index 756f1d6b1..17da11572 100644
--- a/app/_hooks/kanban/useKanban.ts
+++ b/app/_hooks/kanban/useKanban.ts
@@ -8,7 +8,7 @@ import {
updateItemStatus,
createBulkItems,
} from "@/app/_server/actions/checklist-item";
-import { getListById } from "@/app/_server/actions/checklist";
+import { viewList } from "@/app/_server/actions/checklist";
import { getUsername } from "@/app/_server/actions/users";
import { getColumnItems } from "@/app/_utils/kanban/board-utils";
import { useDragStore } from "@/app/_utils/dnd/drag-store";
@@ -67,10 +67,7 @@ export const useKanbanBoard = ({
}, [dragPhase, localChecklist.uuid, localChecklist.updatedAt]);
const refreshChecklist = useCallback(async () => {
- const updatedChecklist = await getListById(
- localChecklist.uuid || "",
- localChecklist.owner,
- );
+ const updatedChecklist = await viewList(localChecklist.uuid || "");
if (updatedChecklist) {
setLocalChecklist(updatedChecklist);
onUpdate(updatedChecklist);
@@ -121,10 +118,7 @@ export const useKanbanBoard = ({
username,
);
- const updatedList = await getListById(
- localChecklist.uuid || "",
- localChecklist.owner || username,
- );
+ const updatedList = await viewList(localChecklist.uuid || "");
if (updatedList) {
setLocalChecklist(updatedList);
onUpdate(updatedList);
diff --git a/app/_hooks/useBottomBarSpace.ts b/app/_hooks/useBottomBarSpace.ts
new file mode 100644
index 000000000..c7b38aeae
--- /dev/null
+++ b/app/_hooks/useBottomBarSpace.ts
@@ -0,0 +1,50 @@
+import { RefObject, useEffect, useRef } from "react";
+import { BottomBarSpaces } from "@/app/_types/enums";
+
+interface BottomBarSpaceOptions {
+ enabled?: boolean;
+ frozen?: boolean;
+}
+
+const occupiedSpace = (bar: HTMLElement) => {
+ if (!bar.offsetHeight) return 0;
+ if (getComputedStyle(bar).position !== "fixed") return 0;
+ return Math.max(0, window.innerHeight - bar.getBoundingClientRect().top);
+};
+
+export const useBottomBarSpace = (
+ barRef: RefObject,
+ space: BottomBarSpaces,
+ { enabled = true, frozen = false }: BottomBarSpaceOptions = {},
+) => {
+ const frozenRef = useRef(frozen);
+
+ useEffect(() => {
+ frozenRef.current = frozen;
+ }, [frozen]);
+
+ useEffect(() => {
+ const bar = barRef.current;
+ const root = document.documentElement;
+ if (!enabled || !bar) return;
+
+ const measure = () => {
+ if (frozenRef.current) return;
+ root.style.setProperty(space, `${Math.ceil(occupiedSpace(bar))}px`);
+ };
+
+ measure();
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(bar);
+ bar.addEventListener("transitionend", measure);
+ window.addEventListener("resize", measure);
+
+ return () => {
+ observer.disconnect();
+ bar.removeEventListener("transitionend", measure);
+ window.removeEventListener("resize", measure);
+ root.style.removeProperty(space);
+ };
+ }, [barRef, space, enabled]);
+};
diff --git a/app/_hooks/useChecklist.tsx b/app/_hooks/useChecklist.tsx
index 08e95ccd1..db4ce5d8b 100644
--- a/app/_hooks/useChecklist.tsx
+++ b/app/_hooks/useChecklist.tsx
@@ -13,7 +13,7 @@ import {
import { Checklist, ChecklistType, Item, RecurrenceRule } from "@/app/_types";
import {
convertChecklistType,
- getListById,
+ viewList,
} from "@/app/_server/actions/checklist";
import { runListDelete } from "@/app/_hooks/lib/delete-list";
import {
@@ -667,10 +667,7 @@ export const useChecklist = ({
}
const result = await createItem(localList, formData, username);
- const updatedList = await getListById(
- localList.uuid || "",
- localList.owner || username,
- );
+ const updatedList = await viewList(localList.uuid || "");
if (updatedList) {
setLocalList(updatedList);
diff --git a/app/_hooks/useMenuPlacement.ts b/app/_hooks/useMenuPlacement.ts
new file mode 100644
index 000000000..2f4bc4f11
--- /dev/null
+++ b/app/_hooks/useMenuPlacement.ts
@@ -0,0 +1,54 @@
+import { RefObject, useCallback, useLayoutEffect, useState } from "react";
+import {
+ MenuAlign,
+ MenuPlacement,
+ MenuSide,
+ menuBounds,
+ placeMenu,
+} from "@/app/_utils/menu-placement-utils";
+
+export const useMenuPlacement = (
+ isOpen: boolean,
+ anchorRef: RefObject,
+ menuRef: RefObject,
+ side: MenuSide = MenuSide.Down,
+ align: MenuAlign = MenuAlign.Start,
+): MenuPlacement => {
+ const [placement, setPlacement] = useState({ side, align });
+
+ const measure = useCallback(() => {
+ const anchor = anchorRef.current;
+ const menu = menuRef.current;
+ if (!anchor || !menu) return;
+
+ const next = placeMenu(
+ anchor.getBoundingClientRect(),
+ { width: menu.offsetWidth, height: menu.offsetHeight },
+ menuBounds(anchor),
+ { side, align },
+ );
+ setPlacement((prev) =>
+ prev.side === next.side && prev.align === next.align ? prev : next,
+ );
+ }, [anchorRef, menuRef, side, align]);
+
+ useLayoutEffect(() => {
+ if (!isOpen) {
+ setPlacement({ side, align });
+ return;
+ }
+
+ measure();
+
+ const vv = window.visualViewport;
+ window.addEventListener("resize", measure);
+ vv?.addEventListener("resize", measure);
+
+ return () => {
+ window.removeEventListener("resize", measure);
+ vv?.removeEventListener("resize", measure);
+ };
+ }, [isOpen, measure, side, align]);
+
+ return placement;
+};
diff --git a/app/_hooks/useNoteEditor.tsx b/app/_hooks/useNoteEditor.tsx
index a6e2d0017..852d74ce8 100644
--- a/app/_hooks/useNoteEditor.tsx
+++ b/app/_hooks/useNoteEditor.tsx
@@ -63,12 +63,9 @@ export const useNoteEditor = ({
const [isPrinting, setIsPrinting] = useState(false);
const notesDefaultMode = user?.notesDefaultMode || "view";
- const [isEditing, setIsEditing] = useState(() => {
- if (note.encrypted) return false;
- const editor = searchParams?.get("editor");
-
- return notesDefaultMode === "edit" || editor === "true" ? true : false;
- });
+ const [isEditing, setIsEditing] = useState(
+ () => !note.encrypted && searchParams?.get("editor") === "true"
+ );
const [status, setStatus] = useState({
isSaving: false,
isAutoSaving: false,
diff --git a/app/_hooks/useReturnPath.ts b/app/_hooks/useReturnPath.ts
new file mode 100644
index 000000000..691f8adb2
--- /dev/null
+++ b/app/_hooks/useReturnPath.ts
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+import { usePathname, useSearchParams } from "next/navigation";
+import { useAppMode } from "@/app/_providers/AppModeProvider";
+import { useReturnPathStore } from "@/app/_utils/return-path-store";
+
+const ITEM_ROUTES = ["/note/", "/checklist/", "/admin/note/", "/admin/checklist/"];
+
+export const useTrackReturnPath = () => {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const { mode } = useAppMode();
+ const setReturnPath = useReturnPathStore((state) => state.setReturnPath);
+
+ useEffect(() => {
+ if (!pathname || ITEM_ROUTES.some((route) => pathname.startsWith(route))) {
+ return;
+ }
+ const params = new URLSearchParams(searchParams?.toString());
+ if (pathname === "/" && !params.has("mode") && mode) {
+ params.set("mode", mode);
+ }
+ const query = params.toString();
+ setReturnPath(query ? `${pathname}?${query}` : pathname);
+ }, [pathname, searchParams, mode, setReturnPath]);
+};
+
+export const useReturnPath = (fallback: string) =>
+ useReturnPathStore((state) => state.returnPath) || fallback;
diff --git a/app/_hooks/useShortcuts.ts b/app/_hooks/useShortcuts.ts
index f9755aa35..aad829ea9 100644
--- a/app/_hooks/useShortcuts.ts
+++ b/app/_hooks/useShortcuts.ts
@@ -6,8 +6,15 @@ type Shortcut = {
shiftKey?: boolean;
altKey?: boolean;
modKey?: boolean;
+ skipInEditable?: boolean;
};
+const EDITABLE_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
+
+const isEditableTarget = (target: EventTarget | null) =>
+ target instanceof HTMLElement &&
+ (target.isContentEditable || EDITABLE_TAGS.includes(target.tagName));
+
export const useShortcuts = (shortcuts: Shortcut[]) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -28,6 +35,9 @@ export const useShortcuts = (shortcuts: Shortcut[]) => {
if (s.code !== event.code) {
return false;
}
+ if (s.skipInEditable && isEditableTarget(event.target)) {
+ return false;
+ }
return true;
});
diff --git a/app/_hooks/useShowEmojis.ts b/app/_hooks/useShowEmojis.ts
index 15595c57a..c6878a415 100644
--- a/app/_hooks/useShowEmojis.ts
+++ b/app/_hooks/useShowEmojis.ts
@@ -1,10 +1,14 @@
import { useSettings } from "@/app/_utils/settings-store";
import { useAppMode } from "@/app/_providers/AppModeProvider";
-export const useShowEmojis = (): boolean => {
+export const useShowEmojis = (ownerChoice?: boolean): boolean => {
const { user } = useAppMode();
const { showEmojis: sessionShowEmojis } = useSettings();
+ if (ownerChoice !== undefined) {
+ return ownerChoice;
+ }
+
if (sessionShowEmojis !== null) {
return sessionShowEmojis;
}
diff --git a/app/_providers/ShortcutsProvider.tsx b/app/_providers/ShortcutsProvider.tsx
index d3816a042..14639aca6 100644
--- a/app/_providers/ShortcutsProvider.tsx
+++ b/app/_providers/ShortcutsProvider.tsx
@@ -7,6 +7,8 @@ import {
ReactNode,
useCallback,
useMemo,
+ useRef,
+ useTransition,
} from "react";
import { useShortcuts } from "@/app/_hooks/useShortcuts";
import { CreateNoteModal } from "@/app/_components/GlobalComponents/Modals/NotesModal/CreateNoteModal";
@@ -21,6 +23,8 @@ import { useAppMode } from "./AppModeProvider";
import { useNavigationGuard } from "./NavigationGuardProvider";
import { createNote } from "@/app/_server/actions/note";
import { generateDateTimeTitle } from "../_utils/date-utils";
+import { useTranslations } from "next-intl";
+import { useToast } from "./ToastProvider";
interface ShortcutContextType {
openCreateNoteModal: (initialCategory?: string) => void;
@@ -53,6 +57,10 @@ export const ShortcutProvider = ({
const router = useRouter();
const { mode, setMode } = useAppMode();
const { checkNavigation } = useNavigationGuard();
+ const { showToast } = useToast();
+ const t = useTranslations();
+ const quickLock = useRef(false);
+ const [isOpeningNote, startOpeningNote] = useTransition();
const [showCreateNoteModal, setShowCreateNoteModal] = useState(false);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
@@ -65,30 +73,62 @@ export const ShortcutProvider = ({
const [initialParentCategory, setInitialParentCategory] =
useState("");
- const openCreateNoteModal = useCallback(
+ const quickCreate = useCallback(
async (category?: string) => {
- if (user?.quickCreateNotes === "enable") {
- const title = generateDateTimeTitle();
- const defaultCategory =
- category || user?.quickCreateNotesCategory || "";
+ if (quickLock.current || isOpeningNote) return;
+ quickLock.current = true;
+ try {
const formData = new FormData();
- formData.append("title", title);
- formData.append("category", defaultCategory);
- formData.append("content", "");
+ formData.append("title", generateDateTimeTitle());
+ formData.append(
+ "category",
+ category || user?.quickCreateNotesCategory || ""
+ );
+ formData.append("rawContent", "");
const result = await createNote(formData);
- if (result.success && result.data?.uuid) {
- router.push(`${itemHref(ItemTypes.NOTE, result.data.uuid)}?editor=true`);
- router.refresh();
+ if (!result.success || !result.data?.uuid) {
+ console.warn("Quick create refused:", result.error);
+ showToast({
+ type: "error",
+ title: t("common.error"),
+ message: result.error || t("notes.createNoteFailed"),
+ });
+ return;
}
- } else {
- setInitialCategory(category || "");
- setShowCreateNoteModal(true);
+
+ const href = itemHref(ItemTypes.NOTE, result.data.uuid);
+ startOpeningNote(() => {
+ router.push(`${href}?editor=true`);
+ router.refresh();
+ });
+ } catch (error) {
+ console.error("Quick create failed:", error);
+ showToast({
+ type: "error",
+ title: t("common.error"),
+ message: t("notes.createNoteFailed"),
+ });
+ } finally {
+ quickLock.current = false;
+ }
+ },
+ [user?.quickCreateNotesCategory, isOpeningNote, router, showToast, t]
+ );
+
+ const openCreateNoteModal = useCallback(
+ (category?: string) => {
+ if (user?.quickCreateNotes === "enable") {
+ checkNavigation(() => quickCreate(category));
+ return;
}
+
+ setInitialCategory(category || "");
+ setShowCreateNoteModal(true);
},
- [user?.quickCreateNotes, user?.quickCreateNotesCategory, router]
+ [user?.quickCreateNotes, checkNavigation, quickCreate]
);
const openCreateChecklistModal = useCallback((category?: string) => {
@@ -112,14 +152,14 @@ export const ShortcutProvider = ({
const shortcuts = useMemo(
() => [
{
- code: "ArrowLeft01Icon",
+ code: "ArrowLeft",
modKey: true,
altKey: true,
shiftKey: true,
handler: () =>
checkNavigation(() => {
setMode(Modes.CHECKLISTS);
- router.push("/");
+ router.push(`/?mode=${Modes.CHECKLISTS}`);
}),
},
{
@@ -130,7 +170,7 @@ export const ShortcutProvider = ({
handler: () =>
checkNavigation(() => {
setMode(Modes.NOTES);
- router.push("/");
+ router.push(`/?mode=${Modes.NOTES}`);
}),
},
{
diff --git a/app/_server/actions/archived/index.ts b/app/_server/actions/archived/index.ts
index a3408d260..c3e2b6049 100644
--- a/app/_server/actions/archived/index.ts
+++ b/app/_server/actions/archived/index.ts
@@ -1,8 +1,8 @@
"use server";
import { Checklist, Note, ItemType } from "@/app/_types";
-import { getUserChecklists } from "@/app/_server/actions/checklist";
-import { getUserNotes } from "@/app/_server/actions/note";
+import { getUserChecklists } from "@/app/_server/actions/checklist/queries";
+import { getUserNotes } from "@/app/_server/actions/note/queries";
import { ARCHIVED_DIR_NAME } from "@/app/_consts/files";
import { getCurrentUser } from "@/app/_server/actions/users";
import { ItemTypes } from "@/app/_types/enums";
diff --git a/app/_server/actions/category/crud.ts b/app/_server/actions/category/crud.ts
index 1528d57cd..f36421e87 100644
--- a/app/_server/actions/category/crud.ts
+++ b/app/_server/actions/category/crud.ts
@@ -21,6 +21,7 @@ import {
} from "@/app/_server/actions/share/target";
import { catUuid } from "@/app/_server/actions/share/category-info";
import { PermissionTypes } from "@/app/_types/enums";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
const _mountTarget = async (mode: Modes, category: string) => {
const username = await getUsername();
@@ -146,7 +147,7 @@ export const createCategory = async (formData: FormData) => {
errorMessage: "Failed to create category",
metadata: { categoryName: name },
});
- return { error: "Failed to create category" };
+ return { error: await failedWith(error, "Failed to create category") };
}
};
@@ -224,7 +225,7 @@ export const deleteCategory = async (formData: FormData) => {
errorMessage: "Failed to delete category",
metadata: { categoryPath },
});
- return { error: "Failed to delete category" };
+ return { error: await failedWith(error, "Failed to delete category") };
}
};
@@ -374,6 +375,6 @@ export const renameCategory = async (formData: FormData) => {
errorMessage: "Failed to rename category",
metadata: { oldPath },
});
- return { error: "Failed to rename category" };
+ return { error: await failedWith(error, "Failed to rename category") };
}
};
diff --git a/app/_server/actions/checklist-item/archive.ts b/app/_server/actions/checklist-item/archive.ts
index 1e6a2585c..b699c7ebc 100644
--- a/app/_server/actions/checklist-item/archive.ts
+++ b/app/_server/actions/checklist-item/archive.ts
@@ -6,9 +6,7 @@ import {
serverWriteFile,
ensureDir,
} from "@/app/_server/actions/file";
-import {
- getListById,
-} from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { getUsername } from "@/app/_server/actions/users";
import { Checklist, Result } from "@/app/_types";
@@ -20,8 +18,9 @@ import {
import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
-export const archiveItem = async (
+const _archiveItem = async (
formData: FormData
): Promise> => {
try {
@@ -103,7 +102,7 @@ export const archiveItem = async (
}
};
-export const unarchiveItem = async (
+const _unarchiveItem = async (
formData: FormData
): Promise> => {
try {
@@ -188,3 +187,13 @@ export const unarchiveItem = async (
return { success: false, error: "Failed to unarchive item" };
}
};
+
+export const archiveItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _archiveItem(formData),
+ );
+
+export const unarchiveItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _unarchiveItem(formData),
+ );
diff --git a/app/_server/actions/checklist-item/bulk-operations.ts b/app/_server/actions/checklist-item/bulk-operations.ts
index 28520706b..dc539c4e1 100644
--- a/app/_server/actions/checklist-item/bulk-operations.ts
+++ b/app/_server/actions/checklist-item/bulk-operations.ts
@@ -6,9 +6,7 @@ import {
getUserModeDir,
serverWriteFile,
} from "@/app/_server/actions/file";
-import {
- getListById,
-} from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import {
listToMarkdown,
areAllItemsCompleted
@@ -26,8 +24,10 @@ import {
import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+import { sessionActor } from "@/app/_server/actions/lib/actor";
-export const createBulkItems = async (
+const _createBulkItems = async (
formData: FormData
): Promise> => {
try {
@@ -119,7 +119,7 @@ export const createBulkItems = async (
}
};
-export const bulkToggleItems = async (
+const _bulkToggleItems = async (
formData: FormData
): Promise> => {
try {
@@ -127,12 +127,14 @@ export const bulkToggleItems = async (
const completed = formData.get("completed") === "true";
const itemIdsStr = formData.get("itemIds") as string;
const completedStatesStr = formData.get("completedStates") as string;
- let currentUser = formData.get("username") as string;
+ const actor = await sessionActor(formData.get("username") as string | null);
- if (!currentUser) {
- currentUser = await getUsername();
+ if ("error" in actor) {
+ return { success: false, error: actor.error };
}
+ const currentUser = actor.username;
+
if (!uuid || !itemIdsStr) {
return { success: false, error: "List uuid and item IDs are required" };
}
@@ -287,19 +289,21 @@ export const bulkToggleItems = async (
}
};
-export const bulkDeleteItems = async (
+const _bulkDeleteItems = async (
formData: FormData
): Promise> => {
try {
const uuid = formData.get("uuid") as string;
const itemIdsStr = formData.get("itemIds") as string;
const itemIdsToDelete = JSON.parse(itemIdsStr) as string[];
- let currentUser = formData.get("username") as string;
+ const actor = await sessionActor(formData.get("username") as string | null);
- if (!currentUser) {
- currentUser = await getUsername();
+ if ("error" in actor) {
+ return { success: false, error: actor.error };
}
+ const currentUser = actor.username;
+
if (!uuid || !itemIdsToDelete || itemIdsToDelete.length === 0) {
return { success: true };
}
@@ -376,3 +380,18 @@ export const bulkDeleteItems = async (
return { success: false, error: "Failed to bulk delete items" };
}
};
+
+export const createBulkItems = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _createBulkItems(formData),
+ );
+
+export const bulkToggleItems = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _bulkToggleItems(formData),
+ );
+
+export const bulkDeleteItems = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _bulkDeleteItems(formData),
+ );
diff --git a/app/_server/actions/checklist-item/crud.ts b/app/_server/actions/checklist-item/crud.ts
index e07690c4b..0f14f16de 100644
--- a/app/_server/actions/checklist-item/crud.ts
+++ b/app/_server/actions/checklist-item/crud.ts
@@ -1,375 +1,33 @@
"use server";
import { revalidatePath } from "next/cache";
-import path from "path";
-import {
- serverWriteFile,
- ensureDir,
-} from "@/app/_server/actions/file";
-import { getListById } from "@/app/_server/actions/checklist";
-import {
- listToMarkdown,
- areAllItemsCompleted,
-} from "@/app/_utils/checklist-utils";
-import {
- extractHashtagsFromContent,
- normalizeTag,
-} from "@/app/_utils/tag-utils";
-import { isAdmin, getUsername } from "@/app/_server/actions/users";
-import { findUserRecord } from "@/app/_server/actions/users/records";
-import { Checklist, Item, KanbanPriority, Result } from "@/app/_types";
-import {
- ItemTypes,
- Modes,
- NotificationTargets,
- PermissionTypes,
- TaskStatus,
-} from "@/app/_types/enums";
-import { canReach, reachableFile } from "@/app/_server/actions/share/queries";
+import { serverWriteFile } from "@/app/_server/actions/file";
+import { getListById } from "@/app/_server/actions/checklist/queries";
+import { areAllItemsCompleted, listToMarkdown } from "@/app/_utils/checklist-utils";
+import { getUsername } from "@/app/_server/actions/users";
+import { Checklist, Item, Result } from "@/app/_types";
+import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums";
+import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
-import { updateAllChildren, findItem } from "@/app/_utils/item-tree-utils";
-import { isKanbanType } from "@/app/_types/enums";
-import { notifyUser } from "@/app/_server/actions/notifications/internal";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+import { sessionActor } from "@/app/_server/actions/lib/actor";
+import { addItem, editItem } from "./editor";
-export const updateItem = async (
- checklist: Checklist,
+const _deleteItem = async (
formData: FormData,
- username?: string,
- skipRevalidation = false,
): Promise> => {
try {
+ const uuid = formData.get("uuid") as string;
const itemId = formData.get("itemId") as string;
- const completedRaw = formData.get("completed");
- const text = formData.get("text") as string;
- const description = formData.get("description") as string;
-
- const currentUser = (await getUsername()) || username;
-
- if (!currentUser) {
- throw new Error("Not authenticated");
- }
-
- const filePath = await reachableFile(
- checklist.uuid,
- ItemTypes.CHECKLIST,
- currentUser,
- PermissionTypes.EDIT,
- );
-
- if (!filePath) {
- throw new Error("Permission denied");
- }
-
- const stored = await getListById(checklist.uuid, currentUser);
-
- if (!stored) {
- throw new Error("List not found");
- }
-
- const _updateParentBasedOnChildren = (parent: Item): Item => {
- if ((parent.children || []).length < 1) return parent;
- return { ...parent, completed: areAllItemsCompleted(parent.children!) };
- };
-
- const _findAndUpdateItem = (items: Item[], itemId: string, updates: Partial- ): Item[] =>
- items.map((item) => {
- if (item.id === itemId) {
- let updatedItem = { ...item, ...updates };
- if (updates.completed && item.children && item.children.length > 0) {
- updatedItem.children = updateAllChildren(item.children, true);
- } else if (updates.completed === false && item.children && item.children.length > 0) {
- updatedItem.children = updateAllChildren(item.children, false);
- }
- return updatedItem;
- }
- if (item.children && item.children.length > 0) {
- return _updateParentBasedOnChildren({
- ...item,
- children: _findAndUpdateItem(item.children, itemId, updates),
- });
- }
- return item;
- });
-
- const now = new Date().toISOString();
-
- const textInlineTags = text ? extractHashtagsFromContent(text) : [];
- const existingTags = stored.tags || [];
- const mergedTags = text
- ? Array.from(
- new Set([...existingTags.map(normalizeTag), ...textInlineTags]),
- ).filter(Boolean)
- : existingTags;
-
- const priority = formData.get("priority") as string | null;
- const score = formData.get("score") as string | null;
- const assignee = formData.get("assignee") as string | null;
- const reminder = formData.get("reminder") as string | null;
- const targetDate = formData.get("targetDate") as string | null;
- const startDate = formData.get("startDate") as string | null;
- const estimatedTime = formData.get("estimatedTime") as string | null;
-
- const updatedList = {
- ...stored,
- items: _findAndUpdateItem(stored.items, itemId, {
- ...(completedRaw !== null && { completed: completedRaw === "true" }),
- ...(text && { text }),
- ...(description !== null &&
- description !== undefined && { description }),
- ...(priority !== null && { priority: (priority || undefined) as KanbanPriority | undefined }),
- ...(score !== null && { score: score ? parseInt(score) : undefined }),
- ...(assignee !== null && { assignee: assignee || undefined }),
- ...(reminder !== null && {
- reminder: reminder ? JSON.parse(reminder) : undefined,
- }),
- ...(targetDate !== null && { targetDate: targetDate || undefined }),
- ...(startDate !== null && { startDate: startDate || undefined }),
- ...(estimatedTime !== null && { estimatedTime: estimatedTime ? parseFloat(estimatedTime) : undefined }),
- lastModifiedBy: currentUser,
- lastModifiedAt: now,
- }),
- tags: mergedTags,
- updatedAt: now,
- };
-
- await ensureDir(path.dirname(filePath));
-
- await serverWriteFile(filePath, listToMarkdown(updatedList));
-
- if (!skipRevalidation) {
- try {
- revalidatePath("/");
- revalidatePath(`/checklist/${stored.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error,
- );
- }
- }
-
- if (assignee && assignee !== currentUser) {
- const assignedItem = findItem(updatedList.items, itemId);
- try {
- await notifyUser(assignee, {
- type: "assignment",
- title: assignedItem?.text || "New task assigned",
- message: `${currentUser} assigned you to a task in "${stored.title}"`,
- data: {
- itemId: stored.uuid,
- itemType: NotificationTargets.CHECKLIST,
- taskId: itemId,
- },
- });
- } catch (error) {
- console.warn("[checklist-item] assignment notification failed:", error);
- }
- }
-
- await broadcast({
- type: "checklist",
- action: "updated",
- entityId: stored.uuid,
- username: currentUser,
- });
-
- return { success: true, data: updatedList as Checklist };
- } catch (error) {
- console.error(
- "Error updating item:",
- error instanceof Error ? error.stack : "No stack trace",
- );
- console.error(
- "Error updating item:",
- error instanceof Error ? error.message : String(error),
- );
- return { success: false, error: "Failed to update item" };
- }
-};
-export const createItem = async (
- list: Checklist,
- formData: FormData,
- username?: string,
- skipRevalidation = false,
-) => {
- try {
- const text = formData.get("text") as string;
- const status = formData.get("status") as string;
- const timeStr = formData.get("time") as string;
- const description = formData.get("description") as string;
- const currentUser = (await getUsername()) || username;
- const recurrenceStr = formData.get("recurrence") as string;
+ const currentUser = await getUsername();
if (!currentUser) {
throw new Error("Not authenticated");
}
- const filePath = await reachableFile(
- list.uuid,
- ItemTypes.CHECKLIST,
- currentUser,
- PermissionTypes.EDIT,
- );
-
- if (!filePath) {
- throw new Error("Permission denied");
- }
-
- const stored = await getListById(list.uuid, currentUser);
-
- if (!stored) {
- throw new Error("List not found");
- }
-
- let timeEntries: any[] = [];
- if (timeStr && timeStr !== "0") {
- try {
- timeEntries = JSON.parse(timeStr);
- } catch (e) {
- console.error("Failed to parse time entries:", e);
- timeEntries = [];
- }
- }
-
- const now = new Date().toISOString();
-
- let recurrence = undefined;
- if (recurrenceStr) {
- try {
- recurrence = JSON.parse(recurrenceStr);
-
- if (recurrence && !recurrence.nextDue) {
- const { calculateNextOccurrence } =
- await import("@/app/_utils/recurrence-utils");
- recurrence.nextDue = calculateNextOccurrence(
- recurrence.rrule,
- recurrence.dtstart,
- );
- }
- } catch (e) {
- console.error("Failed to parse recurrence:", e);
- recurrence = undefined;
- }
- }
-
- const getDefaultStatus = (): TaskStatus => {
- if (status) return status as TaskStatus;
-
- if (stored.statuses && stored.statuses.length > 0) {
- const sortedStatuses = [...stored.statuses].sort(
- (a, b) => a.order - b.order,
- );
- return sortedStatuses[0].id as TaskStatus;
- }
-
- return TaskStatus.TODO;
- };
-
- const defaultStatus = isKanbanType(stored.type)
- ? getDefaultStatus()
- : undefined;
-
- let isSharedBoard = false;
- if (isKanbanType(stored.type)) {
- const { usersWithAccess } = await import("@/app/_server/actions/share/queries");
- const sharedUsers = await usersWithAccess(stored.uuid);
- isSharedBoard = sharedUsers.length > 0;
- }
-
- const shiftedItems = stored.items.map((item) => ({
- ...item,
- order: item.order + 1,
- }));
-
- const userRecord = await findUserRecord(currentUser);
- const insertAtBottom = userRecord?.newItemInsertion === "bottom";
-
- const newItem = {
- id: `${stored.uuid}-${Date.now()}`,
- text,
- completed: false,
- order: insertAtBottom
- ? stored.items.reduce((max, item) => Math.max(max, item.order ?? 0), -1) + 1
- : 0,
- description: description || undefined,
- createdBy: currentUser,
- createdAt: now,
- lastModifiedBy: currentUser,
- lastModifiedAt: now,
- ...(isKanbanType(stored.type) &&
- defaultStatus && {
- status: defaultStatus,
- timeEntries,
- history: [
- {
- status: defaultStatus,
- timestamp: now,
- user: currentUser,
- },
- ],
- }),
- ...(isSharedBoard && { assignee: currentUser }),
- ...(recurrence && { recurrence }),
- };
-
- const inlineTags = extractHashtagsFromContent(text);
- const existingTags = stored.tags || [];
- const mergedTags = Array.from(
- new Set([...existingTags.map(normalizeTag), ...inlineTags]),
- ).filter(Boolean);
-
- const updatedList = {
- ...stored,
- items: insertAtBottom
- ? [...stored.items, newItem]
- : [newItem, ...shiftedItems],
- tags: mergedTags,
- updatedAt: new Date().toISOString(),
- };
-
- await ensureDir(path.dirname(filePath));
-
- await serverWriteFile(filePath, listToMarkdown(updatedList as Checklist));
-
- if (!skipRevalidation) {
- try {
- revalidatePath("/");
- revalidatePath(`/checklist/${stored.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error,
- );
- }
- }
-
- await broadcast({
- type: "checklist",
- action: "updated",
- entityId: stored.uuid,
- username: currentUser,
- });
-
- return { success: true, data: newItem };
- } catch (error) {
- console.error(
- "Error creating item:",
- error instanceof Error ? error.stack : "No stack trace",
- );
- return { success: false, error: "Failed to create item" };
- }
-};
-
-export const deleteItem = async (
- formData: FormData,
-): Promise> => {
- try {
- const uuid = formData.get("uuid") as string;
- const itemId = formData.get("itemId") as string;
-
- const currentUser = await getUsername();
const list = await getListById(uuid, currentUser);
if (!list) {
throw new Error("List not found");
@@ -449,6 +107,42 @@ export const deleteItem = async (
return { success: true, data: updatedList as Checklist };
} catch (error) {
- return { success: false, error: "Failed to delete item" };
+ console.error("Error deleting item:", error);
+ return { success: false, error: await failedWith(error, "Failed to delete item") };
+ }
+};
+
+export const updateItem = async (
+ checklist: Checklist,
+ formData: FormData,
+ username?: string,
+ skipRevalidation = false,
+): Promise> => {
+ const actor = await sessionActor(username);
+
+ if ("error" in actor) {
+ return { success: false, error: actor.error };
+ }
+
+ return editItem(actor, checklist, formData, skipRevalidation);
+};
+
+export const createItem = async (
+ list: Checklist,
+ formData: FormData,
+ username?: string,
+ skipRevalidation = false,
+): Promise> => {
+ const actor = await sessionActor(username);
+
+ if ("error" in actor) {
+ return { success: false, error: actor.error };
}
+
+ return addItem(actor, list, formData, skipRevalidation);
};
+
+export const deleteItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _deleteItem(formData),
+ );
diff --git a/app/_server/actions/checklist-item/drop.ts b/app/_server/actions/checklist-item/drop.ts
index 612a62c82..430de9841 100644
--- a/app/_server/actions/checklist-item/drop.ts
+++ b/app/_server/actions/checklist-item/drop.ts
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import path from "path";
import { serverWriteFile, ensureDir } from "@/app/_server/actions/file";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { getUsername } from "@/app/_server/actions/users";
import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
@@ -13,8 +13,9 @@ import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";
import { Checklist, Result } from "@/app/_types";
import { ItemTypes, PermissionTypes, Modes } from "@/app/_types/enums";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
-export const dropItem = async (
+const _dropItem = async (
formData: FormData,
): Promise> => {
try {
@@ -111,3 +112,8 @@ export const dropItem = async (
return { success: false, error: "Failed to drop item" };
}
};
+
+export const dropItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _dropItem(formData),
+ );
diff --git a/app/_server/actions/checklist-item/editor.ts b/app/_server/actions/checklist-item/editor.ts
new file mode 100644
index 000000000..ea2c3d99a
--- /dev/null
+++ b/app/_server/actions/checklist-item/editor.ts
@@ -0,0 +1,387 @@
+import { revalidatePath } from "next/cache";
+import path from "path";
+import {
+ serverWriteFile,
+ ensureDir,
+} from "@/app/_server/actions/file";
+import { getListById } from "@/app/_server/actions/checklist/queries";
+import {
+ listToMarkdown,
+ areAllItemsCompleted,
+} from "@/app/_utils/checklist-utils";
+import {
+ extractHashtagsFromContent,
+ normalizeTag,
+} from "@/app/_utils/tag-utils";
+import { findUserRecord } from "@/app/_server/actions/users/records";
+import {
+ Checklist,
+ Item,
+ KanbanPriority,
+ Result,
+ SanitisedUser,
+} from "@/app/_types";
+import {
+ ItemTypes,
+ Modes,
+ NotificationTargets,
+ PermissionTypes,
+ TaskStatus,
+} from "@/app/_types/enums";
+import { reachableFile } from "@/app/_server/actions/share/queries";
+import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { updateAllChildren, findItem } from "@/app/_utils/item-tree-utils";
+import { isKanbanType } from "@/app/_types/enums";
+import { notifyUser } from "@/app/_server/actions/notifications/internal";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+
+const _editItem = async (
+ actor: SanitisedUser,
+ checklist: Checklist,
+ formData: FormData,
+ skipRevalidation: boolean,
+): Promise> => {
+ try {
+ const itemId = formData.get("itemId") as string;
+ const completedRaw = formData.get("completed");
+ const text = formData.get("text") as string;
+ const description = formData.get("description") as string;
+
+ const currentUser = actor?.username;
+
+ if (!currentUser) {
+ throw new Error("Not authenticated");
+ }
+
+ const filePath = await reachableFile(
+ checklist.uuid,
+ ItemTypes.CHECKLIST,
+ currentUser,
+ PermissionTypes.EDIT,
+ );
+
+ if (!filePath) {
+ throw new Error("Permission denied");
+ }
+
+ const stored = await getListById(checklist.uuid, currentUser);
+
+ if (!stored) {
+ throw new Error("List not found");
+ }
+
+ const _updateParentBasedOnChildren = (parent: Item): Item => {
+ if ((parent.children || []).length < 1) return parent;
+ return { ...parent, completed: areAllItemsCompleted(parent.children!) };
+ };
+
+ const _findAndUpdateItem = (items: Item[], itemId: string, updates: Partial
- ): Item[] =>
+ items.map((item) => {
+ if (item.id === itemId) {
+ let updatedItem = { ...item, ...updates };
+ if (updates.completed && item.children && item.children.length > 0) {
+ updatedItem.children = updateAllChildren(item.children, true);
+ } else if (updates.completed === false && item.children && item.children.length > 0) {
+ updatedItem.children = updateAllChildren(item.children, false);
+ }
+ return updatedItem;
+ }
+ if (item.children && item.children.length > 0) {
+ return _updateParentBasedOnChildren({
+ ...item,
+ children: _findAndUpdateItem(item.children, itemId, updates),
+ });
+ }
+ return item;
+ });
+
+ const now = new Date().toISOString();
+
+ const textInlineTags = text ? extractHashtagsFromContent(text) : [];
+ const existingTags = stored.tags || [];
+ const mergedTags = text
+ ? Array.from(
+ new Set([...existingTags.map(normalizeTag), ...textInlineTags]),
+ ).filter(Boolean)
+ : existingTags;
+
+ const priority = formData.get("priority") as string | null;
+ const score = formData.get("score") as string | null;
+ const assignee = formData.get("assignee") as string | null;
+ const reminder = formData.get("reminder") as string | null;
+ const targetDate = formData.get("targetDate") as string | null;
+ const startDate = formData.get("startDate") as string | null;
+ const estimatedTime = formData.get("estimatedTime") as string | null;
+
+ const updatedList = {
+ ...stored,
+ items: _findAndUpdateItem(stored.items, itemId, {
+ ...(completedRaw !== null && { completed: completedRaw === "true" }),
+ ...(text && { text }),
+ ...(description !== null &&
+ description !== undefined && { description }),
+ ...(priority !== null && { priority: (priority || undefined) as KanbanPriority | undefined }),
+ ...(score !== null && { score: score ? parseInt(score) : undefined }),
+ ...(assignee !== null && { assignee: assignee || undefined }),
+ ...(reminder !== null && {
+ reminder: reminder ? JSON.parse(reminder) : undefined,
+ }),
+ ...(targetDate !== null && { targetDate: targetDate || undefined }),
+ ...(startDate !== null && { startDate: startDate || undefined }),
+ ...(estimatedTime !== null && { estimatedTime: estimatedTime ? parseFloat(estimatedTime) : undefined }),
+ lastModifiedBy: currentUser,
+ lastModifiedAt: now,
+ }),
+ tags: mergedTags,
+ updatedAt: now,
+ };
+
+ await ensureDir(path.dirname(filePath));
+
+ await serverWriteFile(filePath, listToMarkdown(updatedList));
+
+ if (!skipRevalidation) {
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${stored.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error,
+ );
+ }
+ }
+
+ if (assignee && assignee !== currentUser) {
+ const assignedItem = findItem(updatedList.items, itemId);
+ try {
+ await notifyUser(assignee, {
+ type: "assignment",
+ title: assignedItem?.text || "New task assigned",
+ message: `${currentUser} assigned you to a task in "${stored.title}"`,
+ data: {
+ itemId: stored.uuid,
+ itemType: NotificationTargets.CHECKLIST,
+ taskId: itemId,
+ },
+ });
+ } catch (error) {
+ console.warn("[checklist-item] assignment notification failed:", error);
+ }
+ }
+
+ await broadcast({
+ type: "checklist",
+ action: "updated",
+ entityId: stored.uuid,
+ username: currentUser,
+ });
+
+ return { success: true, data: updatedList as Checklist };
+ } catch (error) {
+ console.error(
+ "Error updating item:",
+ error instanceof Error ? error.stack : "No stack trace",
+ );
+ console.error(
+ "Error updating item:",
+ error instanceof Error ? error.message : String(error),
+ );
+ return { success: false, error: await failedWith(error, "Failed to update item") };
+ }
+};
+
+const _addItem = async (
+ actor: SanitisedUser,
+ list: Checklist,
+ formData: FormData,
+ skipRevalidation: boolean,
+) => {
+ try {
+ const text = formData.get("text") as string;
+ const status = formData.get("status") as string;
+ const timeStr = formData.get("time") as string;
+ const description = formData.get("description") as string;
+ const currentUser = actor?.username;
+ const recurrenceStr = formData.get("recurrence") as string;
+
+ if (!currentUser) {
+ throw new Error("Not authenticated");
+ }
+
+ const filePath = await reachableFile(
+ list.uuid,
+ ItemTypes.CHECKLIST,
+ currentUser,
+ PermissionTypes.EDIT,
+ );
+
+ if (!filePath) {
+ throw new Error("Permission denied");
+ }
+
+ const stored = await getListById(list.uuid, currentUser);
+
+ if (!stored) {
+ throw new Error("List not found");
+ }
+
+ let timeEntries: any[] = [];
+ if (timeStr && timeStr !== "0") {
+ try {
+ timeEntries = JSON.parse(timeStr);
+ } catch (e) {
+ console.error("Failed to parse time entries:", e);
+ timeEntries = [];
+ }
+ }
+
+ const now = new Date().toISOString();
+
+ let recurrence = undefined;
+ if (recurrenceStr) {
+ try {
+ recurrence = JSON.parse(recurrenceStr);
+
+ if (recurrence && !recurrence.nextDue) {
+ const { calculateNextOccurrence } =
+ await import("@/app/_utils/recurrence-utils");
+ recurrence.nextDue = calculateNextOccurrence(
+ recurrence.rrule,
+ recurrence.dtstart,
+ );
+ }
+ } catch (e) {
+ console.error("Failed to parse recurrence:", e);
+ recurrence = undefined;
+ }
+ }
+
+ const getDefaultStatus = (): TaskStatus => {
+ if (status) return status as TaskStatus;
+
+ if (stored.statuses && stored.statuses.length > 0) {
+ const sortedStatuses = [...stored.statuses].sort(
+ (a, b) => a.order - b.order,
+ );
+ return sortedStatuses[0].id as TaskStatus;
+ }
+
+ return TaskStatus.TODO;
+ };
+
+ const defaultStatus = isKanbanType(stored.type)
+ ? getDefaultStatus()
+ : undefined;
+
+ let isSharedBoard = false;
+ if (isKanbanType(stored.type)) {
+ const { usersWithAccess } = await import("@/app/_server/actions/share/queries");
+ const sharedUsers = await usersWithAccess(stored.uuid);
+ isSharedBoard = sharedUsers.length > 0;
+ }
+
+ const shiftedItems = stored.items.map((item) => ({
+ ...item,
+ order: item.order + 1,
+ }));
+
+ const userRecord = await findUserRecord(currentUser);
+ const insertAtBottom = userRecord?.newItemInsertion === "bottom";
+
+ const newItem = {
+ id: `${stored.uuid}-${Date.now()}`,
+ text,
+ completed: false,
+ order: insertAtBottom
+ ? stored.items.reduce((max, item) => Math.max(max, item.order ?? 0), -1) + 1
+ : 0,
+ description: description || undefined,
+ createdBy: currentUser,
+ createdAt: now,
+ lastModifiedBy: currentUser,
+ lastModifiedAt: now,
+ ...(isKanbanType(stored.type) &&
+ defaultStatus && {
+ status: defaultStatus,
+ timeEntries,
+ history: [
+ {
+ status: defaultStatus,
+ timestamp: now,
+ user: currentUser,
+ },
+ ],
+ }),
+ ...(isSharedBoard && { assignee: currentUser }),
+ ...(recurrence && { recurrence }),
+ };
+
+ const inlineTags = extractHashtagsFromContent(text);
+ const existingTags = stored.tags || [];
+ const mergedTags = Array.from(
+ new Set([...existingTags.map(normalizeTag), ...inlineTags]),
+ ).filter(Boolean);
+
+ const updatedList = {
+ ...stored,
+ items: insertAtBottom
+ ? [...stored.items, newItem]
+ : [newItem, ...shiftedItems],
+ tags: mergedTags,
+ updatedAt: new Date().toISOString(),
+ };
+
+ await ensureDir(path.dirname(filePath));
+
+ await serverWriteFile(filePath, listToMarkdown(updatedList as Checklist));
+
+ if (!skipRevalidation) {
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${stored.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error,
+ );
+ }
+ }
+
+ await broadcast({
+ type: "checklist",
+ action: "updated",
+ entityId: stored.uuid,
+ username: currentUser,
+ });
+
+ return { success: true, data: newItem };
+ } catch (error) {
+ console.error(
+ "Error creating item:",
+ error instanceof Error ? error.stack : "No stack trace",
+ );
+ return { success: false, error: await failedWith(error, "Failed to create item") };
+ }
+};
+
+export const editItem = async (
+ actor: SanitisedUser,
+ checklist: Checklist,
+ formData: FormData,
+ skipRevalidation = false,
+) =>
+ runQueued(itemLane(Modes.CHECKLISTS, checklist.uuid), () =>
+ _editItem(actor, checklist, formData, skipRevalidation),
+ );
+
+export const addItem = async (
+ actor: SanitisedUser,
+ list: Checklist,
+ formData: FormData,
+ skipRevalidation = false,
+) =>
+ runQueued(itemLane(Modes.CHECKLISTS, list.uuid), () =>
+ _addItem(actor, list, formData, skipRevalidation),
+ );
diff --git a/app/_server/actions/checklist-item/reorder.ts b/app/_server/actions/checklist-item/reorder.ts
index 052578bd8..55e90beca 100644
--- a/app/_server/actions/checklist-item/reorder.ts
+++ b/app/_server/actions/checklist-item/reorder.ts
@@ -6,15 +6,16 @@ import {
serverWriteFile,
ensureDir,
} from "@/app/_server/actions/file";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { getUsername } from "@/app/_server/actions/users";
import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
-export const reorderItems = async (formData: FormData) => {
+const _reorderItems = async (formData: FormData) => {
try {
const uuid = formData.get("uuid") as string;
const activeItemId = formData.get("activeItemId") as string;
@@ -168,3 +169,8 @@ export const reorderItems = async (formData: FormData) => {
return { success: false, error: "Failed to reorder items" };
}
};
+
+export const reorderItems = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _reorderItems(formData),
+ );
diff --git a/app/_server/actions/checklist-item/stamper.ts b/app/_server/actions/checklist-item/stamper.ts
new file mode 100644
index 000000000..b3d8fc1ca
--- /dev/null
+++ b/app/_server/actions/checklist-item/stamper.ts
@@ -0,0 +1,140 @@
+import { revalidatePath } from "next/cache";
+import path from "path";
+import {
+ serverWriteFile,
+ ensureDir,
+} from "@/app/_server/actions/file";
+import { getListById } from "@/app/_server/actions/checklist/queries";
+import { listToMarkdown } from "@/app/_utils/checklist-utils";
+import { Checklist, Result, SanitisedUser, TimeEntry } from "@/app/_types";
+import {
+ ItemTypes,
+ PermissionTypes,
+ Modes,
+} from "@/app/_types/enums";
+import { canReach } from "@/app/_server/actions/share/queries";
+import { diskPath } from "@/app/_server/actions/share/target";
+import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { updateItem } from "@/app/_utils/item-tree-utils";
+import { applyStatus, completeParent } from "@/app/_utils/item-status-utils";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+
+const _stampStatus = async (
+ actor: SanitisedUser,
+ formData: FormData,
+): Promise> => {
+ try {
+ const uuid = formData.get("uuid") as string;
+ const itemId = formData.get("itemId") as string;
+ const status = formData.get("status") as string;
+ const timeEntriesStr = formData.get("timeEntries") as string;
+ const username = actor?.username;
+
+ if (!username) {
+ return { success: false, error: "Not authenticated" };
+ }
+
+ if (!uuid || !itemId) {
+ return { success: false, error: "List uuid and item ID are required" };
+ }
+
+ if (!status && !timeEntriesStr) {
+ return {
+ success: false,
+ error: "Either status or timeEntries must be provided",
+ };
+ }
+
+ let parsedTimeEntries: TimeEntry[] | null = null;
+ if (timeEntriesStr) {
+ try {
+ const parsed = JSON.parse(timeEntriesStr);
+ if (!Array.isArray(parsed)) {
+ throw new Error("timeEntries must be an array");
+ }
+ const allObjects = parsed.every(
+ (entry) =>
+ entry !== null &&
+ typeof entry === "object" &&
+ !Array.isArray(entry)
+ );
+ if (!allObjects) {
+ throw new Error("timeEntries must contain objects");
+ }
+ parsedTimeEntries = parsed;
+ } catch (e) {
+ console.error("Failed to parse timeEntries:", e);
+ return { success: false, error: "Invalid timeEntries payload" };
+ }
+ }
+
+ const list = await getListById(uuid, username);
+ if (!list) {
+ return { success: false, error: "List not found" };
+ }
+
+ const canEdit = await canReach(
+ list.uuid!,
+ ItemTypes.CHECKLIST,
+ username,
+ PermissionTypes.EDIT
+ );
+
+ if (!canEdit) {
+ return { success: false, error: "Permission denied" };
+ }
+
+ const now = new Date().toISOString();
+
+ const statusItems = status
+ ? applyStatus(list.items, itemId, status, list.statuses, username, now)
+ : list.items;
+
+ const updatedItems = parsedTimeEntries
+ ? updateItem(statusItems, itemId, (item) => ({
+ ...item,
+ timeEntries: parsedTimeEntries!.map((entry) => ({
+ ...entry,
+ user: entry.user || username,
+ })),
+ }))
+ : statusItems;
+
+ const itemsWithParentAutoComplete = completeParent(
+ updatedItems, itemId, list.statuses, username, now
+ );
+
+ const updatedList = {
+ ...list,
+ items: itemsWithParentAutoComplete,
+ updatedAt: now,
+ };
+
+ const filePath = await diskPath(Modes.CHECKLISTS, username, list);
+ await ensureDir(path.dirname(filePath));
+
+ await serverWriteFile(filePath, listToMarkdown(updatedList));
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${list.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error
+ );
+ }
+ await broadcast({ type: "checklist", action: "updated", entityId: list.uuid, username });
+
+ return { success: true, data: updatedList as Checklist };
+ } catch (error) {
+ console.error("Error updating item status:", error);
+ return { success: false, error: await failedWith(error, "Failed to update item status") };
+ }
+};
+
+export const stampStatus = async (actor: SanitisedUser, formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _stampStatus(actor, formData),
+ );
diff --git a/app/_server/actions/checklist-item/status.ts b/app/_server/actions/checklist-item/status.ts
index a6d9358f8..86535c519 100644
--- a/app/_server/actions/checklist-item/status.ts
+++ b/app/_server/actions/checklist-item/status.ts
@@ -1,137 +1,17 @@
"use server";
-import { revalidatePath } from "next/cache";
-import path from "path";
-import {
- serverWriteFile,
- ensureDir,
-} from "@/app/_server/actions/file";
-import {
- getListById,
-} from "@/app/_server/actions/checklist";
-import { listToMarkdown } from "@/app/_utils/checklist-utils";
-import { getUsername } from "@/app/_server/actions/users";
-import { Checklist, Result, TimeEntry } from "@/app/_types";
-import {
- ItemTypes,
- PermissionTypes,
- Modes,
-} from "@/app/_types/enums";
-import { canReach } from "@/app/_server/actions/share/queries";
-import { diskPath } from "@/app/_server/actions/share/target";
-import { broadcast } from "@/app/_server/actions/ws/broadcast";
-import { updateItem } from "@/app/_utils/item-tree-utils";
-import { applyStatus, completeParent } from "@/app/_utils/item-status-utils";
+import { Checklist, Result } from "@/app/_types";
+import { sessionActor } from "@/app/_server/actions/lib/actor";
+import { stampStatus } from "./stamper";
export const updateItemStatus = async (
formData: FormData,
- usernameOverride?: string
): Promise> => {
- try {
- const uuid = formData.get("uuid") as string;
- const itemId = formData.get("itemId") as string;
- const status = formData.get("status") as string;
- const timeEntriesStr = formData.get("timeEntries") as string;
- const formDataUsername = formData.get("username") as string;
+ const actor = await sessionActor(formData.get("username") as string | null);
- const username =
- usernameOverride || formDataUsername || (await getUsername());
-
- if (!uuid || !itemId) {
- return { success: false, error: "List uuid and item ID are required" };
- }
-
- if (!status && !timeEntriesStr) {
- return {
- success: false,
- error: "Either status or timeEntries must be provided",
- };
- }
-
- let parsedTimeEntries: TimeEntry[] | null = null;
- if (timeEntriesStr) {
- try {
- const parsed = JSON.parse(timeEntriesStr);
- if (!Array.isArray(parsed)) {
- throw new Error("timeEntries must be an array");
- }
- const allObjects = parsed.every(
- (entry) =>
- entry !== null &&
- typeof entry === "object" &&
- !Array.isArray(entry)
- );
- if (!allObjects) {
- throw new Error("timeEntries must contain objects");
- }
- parsedTimeEntries = parsed;
- } catch (e) {
- console.error("Failed to parse timeEntries:", e);
- return { success: false, error: "Invalid timeEntries payload" };
- }
- }
-
- const list = await getListById(uuid, username);
- if (!list) {
- return { success: false, error: "List not found" };
- }
-
- const canEdit = await canReach(
- list.uuid!,
- ItemTypes.CHECKLIST,
- username,
- PermissionTypes.EDIT
- );
-
- if (!canEdit) {
- return { success: false, error: "Permission denied" };
- }
-
- const now = new Date().toISOString();
-
- const statusItems = status
- ? applyStatus(list.items, itemId, status, list.statuses, username, now)
- : list.items;
-
- const updatedItems = parsedTimeEntries
- ? updateItem(statusItems, itemId, (item) => ({
- ...item,
- timeEntries: parsedTimeEntries!.map((entry) => ({
- ...entry,
- user: entry.user || username,
- })),
- }))
- : statusItems;
-
- const itemsWithParentAutoComplete = completeParent(
- updatedItems, itemId, list.statuses, username, now
- );
-
- const updatedList = {
- ...list,
- items: itemsWithParentAutoComplete,
- updatedAt: now,
- };
-
- const filePath = await diskPath(Modes.CHECKLISTS, username, list);
- await ensureDir(path.dirname(filePath));
-
- await serverWriteFile(filePath, listToMarkdown(updatedList));
-
- try {
- revalidatePath("/");
- revalidatePath(`/checklist/${list.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error
- );
- }
- await broadcast({ type: "checklist", action: "updated", entityId: list.uuid, username });
-
- return { success: true, data: updatedList as Checklist };
- } catch (error) {
- console.error("Error updating item status:", error);
- return { success: false, error: "Failed to update item status" };
+ if ("error" in actor) {
+ return { success: false, error: actor.error };
}
+
+ return stampStatus(actor, formData);
};
diff --git a/app/_server/actions/checklist-item/sub-items.ts b/app/_server/actions/checklist-item/sub-items.ts
index bb7614adc..1323fa589 100644
--- a/app/_server/actions/checklist-item/sub-items.ts
+++ b/app/_server/actions/checklist-item/sub-items.ts
@@ -6,7 +6,7 @@ import {
serverWriteFile,
ensureDir,
} from "@/app/_server/actions/file";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { getUsername } from "@/app/_server/actions/users";
import { Checklist, Result } from "@/app/_types";
@@ -20,8 +20,9 @@ import {
import { canReach } from "@/app/_server/actions/share/queries";
import { diskPath } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
-export const createSubItem = async (
+const _createSubItem = async (
formData: FormData
): Promise> => {
try {
@@ -146,3 +147,8 @@ export const createSubItem = async (
return { success: false, error: "Failed to create sub-item" };
}
};
+
+export const createSubItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _createSubItem(formData),
+ );
diff --git a/app/_server/actions/checklist/converters.ts b/app/_server/actions/checklist/converters.ts
index edfda364c..959cd1f91 100644
--- a/app/_server/actions/checklist/converters.ts
+++ b/app/_server/actions/checklist/converters.ts
@@ -267,18 +267,10 @@ export const clearAllChecklistItems = async (formData: FormData) => {
const uuid = formData.get("uuid") as string;
const ownerUsername = formData.get("user") as string | null;
const type = formData.get("type") as "completed" | "incomplete";
- const apiUser = formData.get("apiUser") as string | null;
-
- let actingUser = await getCurrentUser();
- if (!actingUser && apiUser) {
- try {
- actingUser = JSON.parse(apiUser);
- } catch {
- return { error: "Invalid user data" };
- }
- }
- if (!actingUser || !actingUser.username) {
+ const actingUser = await getCurrentUser();
+
+ if (!actingUser?.username) {
return { error: "Not authenticated" };
}
diff --git a/app/_server/actions/checklist/creator.ts b/app/_server/actions/checklist/creator.ts
index 6d973af3a..42ea88d84 100644
--- a/app/_server/actions/checklist/creator.ts
+++ b/app/_server/actions/checklist/creator.ts
@@ -10,6 +10,7 @@ import { generateUuid } from "@/app/_utils/yaml-metadata-utils";
import { logContentEvent } from "@/app/_server/actions/log";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
import { getFormData } from "@/app/_utils/global-utils";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
/**
* Server-only checklist creation. The acting principal is passed in already
@@ -90,6 +91,6 @@ export const makeList = async (
false,
);
console.error("Error creating list:", error);
- return { error: "Failed to create list" };
+ return { error: await failedWith(error, "Failed to create list") };
}
};
diff --git a/app/_server/actions/checklist/crud.ts b/app/_server/actions/checklist/crud.ts
index 94f821261..0c61355e7 100644
--- a/app/_server/actions/checklist/crud.ts
+++ b/app/_server/actions/checklist/crud.ts
@@ -2,42 +2,21 @@
import path from "path";
import { Checklist, Item } from "@/app/_types";
-import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums";
import { getCurrentUser } from "@/app/_server/actions/users";
-import {
- ensureDir,
- serverWriteFile,
- serverDeleteFile,
-} from "@/app/_server/actions/file";
+import { ensureDir, serverWriteFile } from "@/app/_server/actions/file";
import { revalidatePath } from "next/cache";
-import { generateUniqueFilename, sanitizeFilename } from "@/app/_utils/filename-utils";
+import { generateUniqueFilename } from "@/app/_utils/filename-utils";
import { listToMarkdown } from "@/app/_utils/checklist-utils";
-import { getFormData } from "@/app/_utils/global-utils";
import { UNCATEGORIZED } from "@/app/_consts/notes";
import { canReach } from "@/app/_server/actions/share/queries";
-import {
- targetDir,
- bouncer,
- shownAs,
- movePlan,
- refusalMessage,
-} from "@/app/_server/actions/share/target";
-import { generateUuid, updateYamlMetadata } from "@/app/_utils/yaml-metadata-utils";
-import { logContentEvent } from "@/app/_server/actions/log";
-import { getListById, getUserChecklists } from "./queries";
+import { targetDir, bouncer } from "@/app/_server/actions/share/target";
+import { generateUuid } from "@/app/_utils/yaml-metadata-utils";
+import { getListById } from "./queries";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
import { claimedName } from "@/app/_server/actions/lib/actor";
import { makeList } from "./creator";
-
-const _listDirFor = (owner: string, category?: string): string =>
- path.join(
- process.cwd(),
- "data",
- CHECKLISTS_FOLDER,
- owner,
- category || UNCATEGORIZED,
- );
+import { dropList, editList } from "./editor";
export const createList = async (formData: FormData) => {
const actor = await getCurrentUser();
@@ -59,275 +38,6 @@ export const createList = async (formData: FormData) => {
return makeList(actor, formData);
};
-export const updateList = async (formData: FormData) => {
- try {
- const uuid = formData.get("uuid") as string;
- const title = formData.get("title") as string;
- const category = formData.get("category") as string;
- const apiUser = formData.get("apiUser") as string | null;
-
- let actingUser = await getCurrentUser();
- if (!actingUser && apiUser) {
- try {
- actingUser = JSON.parse(apiUser);
- } catch {
- return { error: "Invalid user data" };
- }
- }
-
- if (!actingUser || !actingUser.username) {
- return { error: "Not authenticated" };
- }
-
- const currentList = await getListById(uuid);
-
- if (!currentList) {
- throw new Error("List not found");
- }
-
- const canEdit = await canReach(
- currentList.uuid!,
- ItemTypes.CHECKLIST,
- actingUser.username,
- PermissionTypes.EDIT
- );
-
- if (!canEdit) {
- return { error: "Permission denied" };
- }
-
- const shownSource = await shownAs(
- Modes.CHECKLISTS,
- actingUser.username,
- currentList.owner!,
- currentList.category || ""
- );
-
- const shownCategory = category || shownSource;
- const { home, destination, target, isMoving } = await movePlan(
- Modes.CHECKLISTS,
- actingUser.username,
- currentList,
- shownCategory
- );
-
- if (isMoving) {
- const verdict = await bouncer(
- target,
- actingUser.username,
- PermissionTypes.CREATE
- );
-
- if (!verdict.allowed) {
- return { error: verdict.error };
- }
-
- const canRemove = await canReach(
- currentList.uuid!,
- ItemTypes.CHECKLIST,
- actingUser.username,
- PermissionTypes.DELETE
- );
-
- if (!canRemove) {
- return { error: await refusalMessage() };
- }
- }
-
- const updatedList: Checklist = {
- ...currentList,
- title,
- category: destination.category,
- owner: destination.owner,
- items: currentList.items,
- updatedAt: new Date().toISOString(),
- };
-
- const sourceDir = _listDirFor(home.owner, home.category);
- const categoryDir = _listDirFor(destination.owner, destination.category);
- await ensureDir(categoryDir);
-
- const currentId = currentList.id;
- let newFilename: string;
- let newId = currentId;
-
- const fileRenameMode = actingUser?.fileRenameMode || "minimal";
- const sanitizedTitle = sanitizeFilename(title, fileRenameMode);
- const currentFilename = `${currentId}.md`;
- const expectedFilename = `${sanitizedTitle}.md`;
-
- if (title !== currentList.title || currentFilename !== expectedFilename) {
- newFilename = await generateUniqueFilename(
- categoryDir,
- title,
- ".md",
- fileRenameMode
- );
- newId = path.basename(newFilename, ".md");
- } else {
- newFilename = `${currentId}.md`;
- }
-
- if (newId !== currentId) {
- updatedList.id = newId;
- }
-
- const filePath = path.join(categoryDir, newFilename);
-
- const oldFilePath =
- isMoving || newId !== currentId
- ? path.join(sourceDir, `${currentId}.md`)
- : null;
-
- await serverWriteFile(filePath, listToMarkdown(updatedList));
-
- if (oldFilePath && oldFilePath !== filePath) {
- await serverDeleteFile(oldFilePath);
- }
-
- try {
- revalidatePath("/");
- revalidatePath(`/checklist/${currentList.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error
- );
- }
-
- await logContentEvent(
- "checklist_updated",
- "checklist",
- updatedList.uuid!,
- updatedList.title,
- true,
- { category: updatedList.category }
- );
-
- await broadcast({ type: "checklist", action: "updated", entityId: updatedList.uuid, username: actingUser.username });
-
- return {
- success: true,
- data: { ...updatedList, category: shownCategory },
- };
- } catch (error) {
- console.error("Error in updateList:", error);
- try {
- const { title, uuid } = getFormData(formData, ["title", "uuid"]);
- await logContentEvent(
- "checklist_updated",
- "checklist",
- uuid!,
- title || "unknown",
- false
- );
- } catch (logError) {
- console.error("Failed to log the updateList failure:", logError);
- }
- return { error: "Failed to update list" };
- }
-};
-
-export const deleteList = async (formData: FormData) => {
- try {
- const uuid = formData.get("uuid") as string;
- const apiUser = formData.get("apiUser") as string | null;
-
- let currentUser = await getCurrentUser();
- if (!currentUser && apiUser) {
- try {
- currentUser = JSON.parse(apiUser);
- } catch {
- return { error: "Invalid user data" };
- }
- }
-
- if (!currentUser) {
- return { error: "Not authenticated" };
- }
-
- const list = await getListById(uuid);
-
- if (!list) {
- return { error: "List not found" };
- }
-
- const canDelete = await canReach(
- list.uuid!,
- ItemTypes.CHECKLIST,
- currentUser.username,
- PermissionTypes.DELETE
- );
-
- if (!canDelete) {
- return { error: "Permission denied" };
- }
-
- const ownerUsername = list.owner || currentUser.username;
- const source = await targetDir(
- Modes.CHECKLISTS,
- currentUser.username,
- list.category || ""
- );
-
- const verdict = await bouncer(
- source,
- currentUser.username,
- PermissionTypes.DELETE
- );
-
- if (!verdict.allowed) {
- return { error: verdict.error };
- }
-
- const ownerDir = path.join(
- process.cwd(),
- "data",
- CHECKLISTS_FOLDER,
- ownerUsername
- );
- const filePath = path.join(
- ownerDir,
- list.category || UNCATEGORIZED,
- `${list.id}.md`
- );
-
- await serverDeleteFile(filePath);
-
- try {
- revalidatePath("/");
- revalidatePath(`/checklist/${list.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error
- );
- }
- await logContentEvent(
- "checklist_deleted",
- "checklist",
- list.uuid || "unknown",
- list.title || "unknown",
- true,
- { category: list.category }
- );
- await broadcast({ type: "checklist", action: "deleted", entityId: list.uuid, username: currentUser.username });
-
- return { success: true };
- } catch (error) {
- try {
- const { title, uuid } = getFormData(formData, ["title", "uuid"]);
- await logContentEvent(
- "checklist_deleted",
- "checklist",
- uuid || "unknown",
- title || "unknown",
- false
- );
- } catch { }
- return { error: "Failed to delete list" };
- }
-};
export const cloneChecklist = async (formData: FormData) => {
try {
@@ -425,3 +135,23 @@ export const cloneChecklist = async (formData: FormData) => {
return { error: "Failed to clone checklist" };
}
};
+
+export const updateList = async (formData: FormData) => {
+ const actor = await getCurrentUser();
+
+ if (!actor?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ return editList(actor, formData);
+};
+
+export const deleteList = async (formData: FormData) => {
+ const actor = await getCurrentUser();
+
+ if (!actor?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ return dropList(actor, formData);
+};
diff --git a/app/_server/actions/checklist/editor.ts b/app/_server/actions/checklist/editor.ts
new file mode 100644
index 000000000..5c06b7e8b
--- /dev/null
+++ b/app/_server/actions/checklist/editor.ts
@@ -0,0 +1,296 @@
+import path from "path";
+import { Checklist, SanitisedUser } from "@/app/_types";
+import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
+import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums";
+import {
+ ensureDir,
+ serverWriteFile,
+ serverDeleteFile,
+} from "@/app/_server/actions/file";
+import { revalidatePath } from "next/cache";
+import { generateUniqueFilename, sanitizeFilename } from "@/app/_utils/filename-utils";
+import { listToMarkdown } from "@/app/_utils/checklist-utils";
+import { getFormData } from "@/app/_utils/global-utils";
+import { UNCATEGORIZED } from "@/app/_consts/notes";
+import { canReach } from "@/app/_server/actions/share/queries";
+import {
+ targetDir,
+ bouncer,
+ shownAs,
+ movePlan,
+ refusalMessage,
+} from "@/app/_server/actions/share/target";
+import { logContentEvent } from "@/app/_server/actions/log";
+import { getListById } from "./queries";
+import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+
+const _listDirFor = (owner: string, category?: string): string =>
+ path.join(
+ process.cwd(),
+ "data",
+ CHECKLISTS_FOLDER,
+ owner,
+ category || UNCATEGORIZED,
+ );
+
+const _editList = async (actingUser: SanitisedUser, formData: FormData) => {
+ try {
+ const uuid = formData.get("uuid") as string;
+ const title = formData.get("title") as string;
+ const category = formData.get("category") as string;
+
+ if (!actingUser?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ const currentList = await getListById(uuid);
+
+ if (!currentList) {
+ throw new Error("List not found");
+ }
+
+ const canEdit = await canReach(
+ currentList.uuid!,
+ ItemTypes.CHECKLIST,
+ actingUser.username,
+ PermissionTypes.EDIT
+ );
+
+ if (!canEdit) {
+ return { error: "Permission denied" };
+ }
+
+ const shownSource = await shownAs(
+ Modes.CHECKLISTS,
+ actingUser.username,
+ currentList.owner!,
+ currentList.category || ""
+ );
+
+ const shownCategory = category || shownSource;
+ const { home, destination, target, isMoving } = await movePlan(
+ Modes.CHECKLISTS,
+ actingUser.username,
+ currentList,
+ shownCategory
+ );
+
+ if (isMoving) {
+ const verdict = await bouncer(
+ target,
+ actingUser.username,
+ PermissionTypes.CREATE
+ );
+
+ if (!verdict.allowed) {
+ return { error: verdict.error };
+ }
+
+ const canRemove = await canReach(
+ currentList.uuid!,
+ ItemTypes.CHECKLIST,
+ actingUser.username,
+ PermissionTypes.DELETE
+ );
+
+ if (!canRemove) {
+ return { error: await refusalMessage() };
+ }
+ }
+
+ const updatedList: Checklist = {
+ ...currentList,
+ title,
+ category: destination.category,
+ owner: destination.owner,
+ items: currentList.items,
+ updatedAt: new Date().toISOString(),
+ };
+
+ const sourceDir = _listDirFor(home.owner, home.category);
+ const categoryDir = _listDirFor(destination.owner, destination.category);
+ await ensureDir(categoryDir);
+
+ const currentId = currentList.id;
+ let newFilename: string;
+ let newId = currentId;
+
+ const fileRenameMode = actingUser?.fileRenameMode || "minimal";
+ const sanitizedTitle = sanitizeFilename(title, fileRenameMode);
+ const currentFilename = `${currentId}.md`;
+ const expectedFilename = `${sanitizedTitle}.md`;
+
+ if (title !== currentList.title || currentFilename !== expectedFilename) {
+ newFilename = await generateUniqueFilename(
+ categoryDir,
+ title,
+ ".md",
+ fileRenameMode
+ );
+ newId = path.basename(newFilename, ".md");
+ } else {
+ newFilename = `${currentId}.md`;
+ }
+
+ if (newId !== currentId) {
+ updatedList.id = newId;
+ }
+
+ const filePath = path.join(categoryDir, newFilename);
+
+ const oldFilePath =
+ isMoving || newId !== currentId
+ ? path.join(sourceDir, `${currentId}.md`)
+ : null;
+
+ await serverWriteFile(filePath, listToMarkdown(updatedList));
+
+ if (oldFilePath && oldFilePath !== filePath) {
+ await serverDeleteFile(oldFilePath);
+ }
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${currentList.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error
+ );
+ }
+
+ await logContentEvent(
+ "checklist_updated",
+ "checklist",
+ updatedList.uuid!,
+ updatedList.title,
+ true,
+ { category: updatedList.category }
+ );
+
+ await broadcast({ type: "checklist", action: "updated", entityId: updatedList.uuid, username: actingUser.username });
+
+ return {
+ success: true,
+ data: { ...updatedList, category: shownCategory },
+ };
+ } catch (error) {
+ console.error("Error in updateList:", error);
+ try {
+ const { title, uuid } = getFormData(formData, ["title", "uuid"]);
+ await logContentEvent(
+ "checklist_updated",
+ "checklist",
+ uuid!,
+ title || "unknown",
+ false
+ );
+ } catch (logError) {
+ console.error("Failed to log the updateList failure:", logError);
+ }
+ return { error: await failedWith(error, "Failed to update list") };
+ }
+};
+
+export const editList = async (actor: SanitisedUser, formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _editList(actor, formData),
+ );
+
+export const dropList = async (
+ currentUser: SanitisedUser,
+ formData: FormData,
+): Promise<{ success?: boolean; error?: string }> => {
+ try {
+ const uuid = formData.get("uuid") as string;
+
+ if (!currentUser?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ const list = await getListById(uuid);
+
+ if (!list) {
+ return { error: "List not found" };
+ }
+
+ const canDelete = await canReach(
+ list.uuid!,
+ ItemTypes.CHECKLIST,
+ currentUser.username,
+ PermissionTypes.DELETE
+ );
+
+ if (!canDelete) {
+ return { error: "Permission denied" };
+ }
+
+ const ownerUsername = list.owner || currentUser.username;
+ const source = await targetDir(
+ Modes.CHECKLISTS,
+ currentUser.username,
+ list.category || ""
+ );
+
+ const verdict = await bouncer(
+ source,
+ currentUser.username,
+ PermissionTypes.DELETE
+ );
+
+ if (!verdict.allowed) {
+ return { error: verdict.error };
+ }
+
+ const ownerDir = path.join(
+ process.cwd(),
+ "data",
+ CHECKLISTS_FOLDER,
+ ownerUsername
+ );
+ const filePath = path.join(
+ ownerDir,
+ list.category || UNCATEGORIZED,
+ `${list.id}.md`
+ );
+
+ await serverDeleteFile(filePath);
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/checklist/${list.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error
+ );
+ }
+ await logContentEvent(
+ "checklist_deleted",
+ "checklist",
+ list.uuid || "unknown",
+ list.title || "unknown",
+ true,
+ { category: list.category }
+ );
+ await broadcast({ type: "checklist", action: "deleted", entityId: list.uuid, username: currentUser.username });
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error deleting list:", error);
+ try {
+ const { title, uuid } = getFormData(formData, ["title", "uuid"]);
+ await logContentEvent(
+ "checklist_deleted",
+ "checklist",
+ uuid || "unknown",
+ title || "unknown",
+ false
+ );
+ } catch { }
+ return { error: await failedWith(error, "Failed to delete list") };
+ }
+};
+
diff --git a/app/_server/actions/checklist/index.ts b/app/_server/actions/checklist/index.ts
index 76235da2a..e1d78a378 100644
--- a/app/_server/actions/checklist/index.ts
+++ b/app/_server/actions/checklist/index.ts
@@ -1,10 +1,5 @@
export { readListsRecursively } from "./readers";
-export {
- getUserChecklists,
- getListById,
- getAllLists,
- getChecklistsForDisplay,
-} from "./queries";
+export { viewList, getChecklistsForDisplay } from "./viewer";
export { createList, updateList, deleteList, cloneChecklist } from "./crud";
export {
convertChecklistType,
diff --git a/app/_server/actions/checklist/queries.ts b/app/_server/actions/checklist/queries.ts
index 08e3a639b..aa5bf5417 100644
--- a/app/_server/actions/checklist/queries.ts
+++ b/app/_server/actions/checklist/queries.ts
@@ -1,5 +1,3 @@
-"use server";
-
import path from "path";
import fs from "fs/promises";
import { Checklist, User, GetChecklistsOptions } from "@/app/_types";
@@ -392,15 +390,3 @@ export const getAllLists = async (
return { success: false, error: "Failed to fetch all lists" };
}
};
-
-export const getChecklistsForDisplay = async (
- filter?: { type: "category" | "tag"; value: string } | null,
- limit: number = 20,
- offset: number = 0,
-) => {
- return getUserChecklists({
- filter: filter || undefined,
- limit,
- offset: filter ? offset : undefined,
- });
-};
diff --git a/app/_server/actions/checklist/readers.ts b/app/_server/actions/checklist/readers.ts
index 7bc9a5070..b51b96afe 100644
--- a/app/_server/actions/checklist/readers.ts
+++ b/app/_server/actions/checklist/readers.ts
@@ -26,7 +26,7 @@ import { orderByUuids } from "@/app/_utils/order-utils";
import { getChecklistType } from "./parsers";
import { isDebugFlag } from "@/app/_utils/env-utils";
import { isKanbanType } from "@/app/_types/enums";
-import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid";
+import { lacksUuid, stampUuid } from "@/app/_server/actions/lib/stamp-uuid";
import { SHARED_WITH_KEY } from "@/app/_consts/sharing";
const execAsync = promisify(exec);
@@ -245,7 +245,7 @@ export const readListsRecursively = async (
rawContent: content,
};
}
- return parseMarkdown(
+ const list = parseMarkdown(
content,
id,
categoryPath,
@@ -257,6 +257,8 @@ export const readListsRecursively = async (
},
fileName,
);
+ if (!lacksUuid(content)) return list;
+ return { ...list, uuid: (await stampUuid(filePath)) || list.uuid };
} catch {
return null;
}
diff --git a/app/_server/actions/checklist/viewer.ts b/app/_server/actions/checklist/viewer.ts
new file mode 100644
index 000000000..cba1b807f
--- /dev/null
+++ b/app/_server/actions/checklist/viewer.ts
@@ -0,0 +1,40 @@
+"use server";
+
+import { Checklist } from "@/app/_types";
+import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
+import { sessionActor } from "@/app/_server/actions/lib/actor";
+import { canReach } from "@/app/_server/actions/share/queries";
+import { getListById, getUserChecklists } from "./queries";
+
+export const viewList = async (
+ uuid: string,
+): Promise => {
+ if (!uuid) return undefined;
+
+ const actor = await sessionActor();
+ if ("error" in actor) return undefined;
+
+ const seen = await getListById(uuid, actor.username);
+ if (seen) return seen;
+
+ const allowed = await canReach(
+ uuid,
+ ItemTypes.CHECKLIST,
+ actor.username,
+ PermissionTypes.READ,
+ );
+
+ return allowed ? getListById(uuid) : undefined;
+};
+
+export const getChecklistsForDisplay = async (
+ filter?: { type: "category" | "tag"; value: string } | null,
+ limit: number = 20,
+ offset: number = 0,
+) => {
+ return getUserChecklists({
+ filter: filter || undefined,
+ limit,
+ offset: filter ? offset : undefined,
+ });
+};
diff --git a/app/_server/actions/comments/index.ts b/app/_server/actions/comments/index.ts
index d499c7b9a..c04d0761b 100644
--- a/app/_server/actions/comments/index.ts
+++ b/app/_server/actions/comments/index.ts
@@ -7,7 +7,7 @@ import {
NotificationTargets,
PermissionTypes,
} from "@/app/_types/enums";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { canReach } from "@/app/_server/actions/share/queries";
import { getUsername, isAdmin, getUsers } from "@/app/_server/actions/users";
import { notifyUser } from "@/app/_server/actions/notifications/internal";
diff --git a/app/_server/actions/config/helpers.ts b/app/_server/actions/config/helpers.ts
index e3a4ec861..73d194771 100644
--- a/app/_server/actions/config/helpers.ts
+++ b/app/_server/actions/config/helpers.ts
@@ -4,10 +4,10 @@ import path from "path";
import fs from "fs/promises";
import { Result } from "@/app/_types";
import { getCurrentUser } from "../users";
-import { getListById } from "../checklist";
+import { getListById } from "../checklist/queries";
import { Metadata } from "next";
import { Modes } from "@/app/_types/enums";
-import { getNoteById } from "../note";
+import { getNoteById } from "../note/queries";
import { getSettings } from "./settings";
export const getMedatadaTitle = async (
diff --git a/app/_server/actions/config/settings.ts b/app/_server/actions/config/settings.ts
index 6e3cfe208..8383e2735 100644
--- a/app/_server/actions/config/settings.ts
+++ b/app/_server/actions/config/settings.ts
@@ -227,19 +227,15 @@ export const updateAppSettings = async (
(formData.get("defaultTimeFormat") as "12-hours" | "24-hours") ||
"12-hours";
- let editorSettings = {
- enableSlashCommands: true,
- enableBubbleMenu: true,
- enableTableToolbar: true,
- enableBilateralLinks: true,
- };
+ const existing = await getSettings();
+ let editorSettings: AppSettings["editor"] = existing.editor;
- const editorData = formData.get("editor") as string;
- if (editorData) {
+ const editorData = formData.get("editor");
+ if (typeof editorData === "string" && editorData) {
try {
- editorSettings = JSON.parse(editorData);
+ editorSettings = { ...existing.editor, ...JSON.parse(editorData) };
} catch (error) {
- console.warn("Failed to parse editor settings, using defaults");
+ console.warn("Unparseable editor settings, keeping current ones:", error);
}
}
diff --git a/app/_server/actions/dashboard/index.ts b/app/_server/actions/dashboard/index.ts
index 13fe16451..55120b9ff 100644
--- a/app/_server/actions/dashboard/index.ts
+++ b/app/_server/actions/dashboard/index.ts
@@ -3,7 +3,8 @@
import { AppMode, Checklist, ItemType, Note, Result } from "@/app/_types";
import { ItemTypes, Modes } from "@/app/_types/enums";
import { updateList } from "../checklist";
-import { updateNote, getNoteById } from "../note";
+import { updateNote } from "../note";
+import { getNoteById } from "../note/queries";
import { getCurrentUser, getUserIndex } from "../users";
import { readJsonFile, writeJsonFile } from "../file";
import { ARCHIVED_DIR_NAME, USERS_FILE } from "@/app/_consts/files";
diff --git a/app/_server/actions/export/index.ts b/app/_server/actions/export/index.ts
index 50bc1522a..bd126593a 100644
--- a/app/_server/actions/export/index.ts
+++ b/app/_server/actions/export/index.ts
@@ -6,8 +6,8 @@ import fsp from "fs/promises";
import path from "path";
import { ExportResult, ExportProgress } from "@/app/_types";
import { DATA_DIR, USERS_FILE, EXPORT_TEMP_DIR } from "@/app/_consts/files";
-import { getAllLists } from "@/app/_server/actions/checklist";
-import { getAllNotes } from "@/app/_server/actions/note";
+import { getAllLists } from "@/app/_server/actions/checklist/queries";
+import { getAllNotes } from "@/app/_server/actions/note/queries";
import { readJsonFile, ensureDir } from "@/app/_server/actions/file";
import {
getCurrentUser,
diff --git a/app/_server/actions/file/index.ts b/app/_server/actions/file/index.ts
index 085b754d8..114af6f82 100644
--- a/app/_server/actions/file/index.ts
+++ b/app/_server/actions/file/index.ts
@@ -12,6 +12,7 @@ import { randomUUID } from "crypto";
import { Modes } from "@/app/_types/enums";
import { atomicWrite, withCreatedAt } from "@/app/_server/actions/file/atomic";
import { isItemFile } from "@/app/_server/actions/relations/paths";
+import { isReadOnlyError } from "@/app/_server/actions/lib/read-only";
export interface OrderData {
categories?: string[];
@@ -168,6 +169,8 @@ export const serverDeleteFile = async (filePath: string) => {
await fs.unlink(filePath);
await (await _tracking()).trackItemDelete(filePath);
} catch (error) {
+ if (isReadOnlyError(error)) throw error;
+
const { logAudit } = await import("@/app/_server/actions/log");
await logAudit({
level: "DEBUG",
@@ -215,6 +218,8 @@ export const serverDeleteDir = async (dirPath: string) => {
await fs.rm(dirPath, { recursive: true });
await (await _tracking()).trackTreeDelete(dirPath);
} catch (error) {
+ if (isReadOnlyError(error)) throw error;
+
const { logAudit } = await import("@/app/_server/actions/log");
await logAudit({
level: "DEBUG",
diff --git a/app/_server/actions/history/index.ts b/app/_server/actions/history/index.ts
index 12dccb569..08cab7cbc 100644
--- a/app/_server/actions/history/index.ts
+++ b/app/_server/actions/history/index.ts
@@ -343,7 +343,7 @@ export const getHistory = async (
await ensureRepo(username);
const git = _getGitInstance(userDir);
- const { getNoteById } = await import("@/app/_server/actions/note");
+ const { getNoteById } = await import("@/app/_server/actions/note/queries");
const note = await getNoteById(noteUuid, username);
if (!note) {
@@ -468,7 +468,7 @@ export const getVersion = async (
}
if (content === null) {
- const { getNoteById } = await import("@/app/_server/actions/note");
+ const { getNoteById } = await import("@/app/_server/actions/note/queries");
const note = await getNoteById(noteUuid, username);
if (note) {
diff --git a/app/_server/actions/kanban/calendar.ts b/app/_server/actions/kanban/calendar.ts
index 42acc147c..0a3e05714 100644
--- a/app/_server/actions/kanban/calendar.ts
+++ b/app/_server/actions/kanban/calendar.ts
@@ -1,7 +1,7 @@
"use server";
import { getFormData } from "@/app/_utils/global-utils";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { generateICS } from "@/app/_utils/kanban/calendar-utils";
import { parseItemsForCalendar, CalendarEvent } from "@/app/_utils/kanban/calendar-utils";
diff --git a/app/_server/actions/kanban/items.ts b/app/_server/actions/kanban/items.ts
index bbd8e9442..e4c33810a 100644
--- a/app/_server/actions/kanban/items.ts
+++ b/app/_server/actions/kanban/items.ts
@@ -17,9 +17,10 @@ import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { getFormData } from "@/app/_utils/global-utils";
import { canReach } from "@/app/_server/actions/share/queries";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { notifyUser } from "@/app/_server/actions/notifications/internal";
import { findItem, updateItem } from "@/app/_utils/item-tree-utils";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
const _getFilePath = async (list: Checklist): Promise => {
const categoryDir = list.category || UNCATEGORIZED;
@@ -51,7 +52,7 @@ async function _saveAndBroadcast(list: Checklist, username: string) {
} catch { }
}
-export const updateKanbanItemPriority = async (formData: FormData) => {
+const _updateKanbanItemPriority = async (formData: FormData) => {
try {
const { uuid, itemId, priority } = getFormData(formData, [
"uuid", "itemId", "priority",
@@ -89,7 +90,7 @@ export const updateKanbanItemPriority = async (formData: FormData) => {
}
};
-export const updateKanbanItemScore = async (formData: FormData) => {
+const _updateKanbanItemScore = async (formData: FormData) => {
try {
const { uuid, itemId, score } = getFormData(formData, [
"uuid", "itemId", "score",
@@ -127,7 +128,7 @@ export const updateKanbanItemScore = async (formData: FormData) => {
}
};
-export const assignKanbanItem = async (formData: FormData) => {
+const _assignKanbanItem = async (formData: FormData) => {
try {
const { uuid, itemId, assignee } = getFormData(formData, [
"uuid", "itemId", "assignee",
@@ -180,7 +181,7 @@ export const assignKanbanItem = async (formData: FormData) => {
}
};
-export const setKanbanItemReminder = async (formData: FormData) => {
+const _setKanbanItemReminder = async (formData: FormData) => {
try {
const { uuid, itemId, reminder: reminderStr } = getFormData(formData, [
"uuid", "itemId", "reminder",
@@ -227,3 +228,22 @@ export const setKanbanItemReminder = async (formData: FormData) => {
}
};
+export const updateKanbanItemPriority = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _updateKanbanItemPriority(formData),
+ );
+
+export const updateKanbanItemScore = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _updateKanbanItemScore(formData),
+ );
+
+export const assignKanbanItem = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _assignKanbanItem(formData),
+ );
+
+export const setKanbanItemReminder = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _setKanbanItemReminder(formData),
+ );
diff --git a/app/_server/actions/kanban/search.ts b/app/_server/actions/kanban/search.ts
index 47c44987a..647f81343 100644
--- a/app/_server/actions/kanban/search.ts
+++ b/app/_server/actions/kanban/search.ts
@@ -2,7 +2,7 @@
import { Checklist, Item, KanbanPriority } from "@/app/_types";
import { getCurrentUser } from "@/app/_server/actions/users";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { getFormData } from "@/app/_utils/global-utils";
const _matchesText = (item: Item, query: string): boolean => {
diff --git a/app/_server/actions/kanban/tempo.ts b/app/_server/actions/kanban/tempo.ts
index 645b788a5..0f01238a5 100644
--- a/app/_server/actions/kanban/tempo.ts
+++ b/app/_server/actions/kanban/tempo.ts
@@ -2,7 +2,7 @@
import { Item, TimeEntry } from "@/app/_types";
import { getCurrentUser } from "@/app/_server/actions/users";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { canReach } from "@/app/_server/actions/share/queries";
import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
import { getFormData } from "@/app/_utils/global-utils";
diff --git a/app/_server/actions/kanban/time-entries.ts b/app/_server/actions/kanban/time-entries.ts
index 1dd23e772..38cc179cd 100644
--- a/app/_server/actions/kanban/time-entries.ts
+++ b/app/_server/actions/kanban/time-entries.ts
@@ -3,7 +3,7 @@
import { Checklist, TimeEntry } from "@/app/_types";
import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
import { getCurrentUser } from "@/app/_server/actions/users";
-import { getListById } from "@/app/_server/actions/checklist";
+import { getListById } from "@/app/_server/actions/checklist/queries";
import { canReach } from "@/app/_server/actions/share/queries";
import { getFormData } from "@/app/_utils/global-utils";
import { updateItem, findItem } from "@/app/_utils/item-tree-utils";
@@ -15,6 +15,7 @@ import { listToMarkdown } from "@/app/_utils/checklist-utils";
import { Modes } from "@/app/_types/enums";
import { revalidatePath } from "next/cache";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
const _getFilePath = async (list: Checklist): Promise => {
const categoryDir = list.category || UNCATEGORIZED;
@@ -46,7 +47,7 @@ async function _saveAndBroadcast(list: Checklist, username: string) {
} catch { }
}
-export const editTimeEntry = async (formData: FormData) => {
+const _editTimeEntry = async (formData: FormData) => {
try {
const { uuid, itemId, entryId } = getFormData(formData, [
"uuid", "itemId", "entryId",
@@ -100,7 +101,7 @@ export const editTimeEntry = async (formData: FormData) => {
}
};
-export const deleteTimeEntry = async (formData: FormData) => {
+const _deleteTimeEntry = async (formData: FormData) => {
try {
const { uuid, itemId, entryId } = getFormData(formData, [
"uuid", "itemId", "entryId",
@@ -139,3 +140,13 @@ export const deleteTimeEntry = async (formData: FormData) => {
return { error: "Failed to delete time entry" };
}
};
+
+export const editTimeEntry = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _editTimeEntry(formData),
+ );
+
+export const deleteTimeEntry = async (formData: FormData) =>
+ runQueued(itemLane(Modes.CHECKLISTS, formData.get("uuid") as string), () =>
+ _deleteTimeEntry(formData),
+ );
diff --git a/app/_server/actions/lib/actor.ts b/app/_server/actions/lib/actor.ts
index b0c3a3359..656b2b89c 100644
--- a/app/_server/actions/lib/actor.ts
+++ b/app/_server/actions/lib/actor.ts
@@ -1,3 +1,6 @@
+import { SanitisedUser } from "@/app/_types";
+import { getCurrentUser } from "@/app/_server/actions/users";
+
/**
* Client callers still ship a serialised user blob in their FormData. It is
* never an identity source, only something we refuse when it disagrees with
@@ -18,3 +21,23 @@ export const claimedName = (formData: FormData): string | null => {
return null;
}
};
+
+export const sessionActor = async (
+ claimed?: string | null,
+): Promise => {
+ const actor = await getCurrentUser();
+
+ if (!actor?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ if (claimed && claimed !== actor.username) {
+ console.error(
+ "Refusing change, claimed identity does not match session:",
+ actor.username,
+ );
+ return { error: "Identity mismatch" };
+ }
+
+ return actor;
+};
diff --git a/app/_server/actions/lib/concurrency.ts b/app/_server/actions/lib/concurrency.ts
index 442828eda..7cfcb6a5c 100644
--- a/app/_server/actions/lib/concurrency.ts
+++ b/app/_server/actions/lib/concurrency.ts
@@ -41,3 +41,6 @@ export const runQueued = (
queues.set(key, tail);
return result;
};
+
+export const itemLane = (mode: string, uuid: string): string =>
+ `item:${mode}:${uuid}`;
diff --git a/app/_server/actions/lib/migration-check.ts b/app/_server/actions/lib/migration-check.ts
index 799c4f2c1..52650662d 100644
--- a/app/_server/actions/lib/migration-check.ts
+++ b/app/_server/actions/lib/migration-check.ts
@@ -9,6 +9,7 @@ import {
SCHEMA_VERSION_FILE,
} from "@/app/_consts/files";
import { LEGACY_ORDER_FILE, LEGACY_SHARING_FILE } from "@/app/_consts/sharing";
+import { isWritable } from "@/app/_server/actions/lib/read-only";
const CHECKED_MODES = [Modes.NOTES, Modes.CHECKLISTS];
@@ -42,7 +43,10 @@ const _hasOrderFile = async (dirPath: string): Promise => {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
- if (entry.isFile() && entry.name === LEGACY_ORDER_FILE) return true;
+ if (entry.isFile() && entry.name === LEGACY_ORDER_FILE) {
+ if (await isWritable(dirPath)) return true;
+ continue;
+ }
if (entry.isDirectory() && !excluded.includes(entry.name)) {
if (await _hasOrderFile(path.join(dirPath, entry.name))) return true;
diff --git a/app/_server/actions/lib/read-only-message.ts b/app/_server/actions/lib/read-only-message.ts
new file mode 100644
index 000000000..c52656a34
--- /dev/null
+++ b/app/_server/actions/lib/read-only-message.ts
@@ -0,0 +1,12 @@
+import { getTranslations } from "next-intl/server";
+import { isReadOnlyError } from "@/app/_server/actions/lib/read-only";
+
+export const failedWith = async (
+ error: unknown,
+ fallback: string,
+): Promise => {
+ if (!isReadOnlyError(error)) return fallback;
+
+ const t = await getTranslations("errors");
+ return t("readOnlyFolder");
+};
diff --git a/app/_server/actions/lib/read-only.ts b/app/_server/actions/lib/read-only.ts
new file mode 100644
index 000000000..942ec60d3
--- /dev/null
+++ b/app/_server/actions/lib/read-only.ts
@@ -0,0 +1,50 @@
+import path from "path";
+import fs from "fs/promises";
+import { constants } from "fs";
+import { v5 as uuidv5 } from "uuid";
+import { DATA_DIR } from "@/app/_consts/files";
+import { PATH_UUID_NAMESPACE, isUuid } from "@/app/_consts/identity";
+
+export enum ReadOnlyCodes {
+ READ_ONLY_FS = "EROFS",
+ ACCESS = "EACCES",
+ PERMISSION = "EPERM",
+}
+
+const READ_ONLY_CODES: string[] = Object.values(ReadOnlyCodes);
+const PATH_UUID_VERSION = "5";
+
+const warnedDirs = new Set();
+
+export const isReadOnlyError = (error: unknown): boolean => {
+ const code = (error as { code?: unknown } | null)?.code;
+ return typeof code === "string" && READ_ONLY_CODES.includes(code);
+};
+
+export const pathUuid = (filePath: string): string => {
+ const dataRoot = path.resolve(process.cwd(), DATA_DIR);
+ const relative = path.relative(dataRoot, path.resolve(filePath));
+ return uuidv5(relative.split(path.sep).join("/"), PATH_UUID_NAMESPACE);
+};
+
+export const isPathUuid = (uuid: string): boolean =>
+ isUuid(uuid) && uuid.charAt(14) === PATH_UUID_VERSION;
+
+export const warnReadOnly = (dirPath: string): void => {
+ const key = path.resolve(dirPath);
+ if (warnedDirs.has(key)) return;
+
+ warnedDirs.add(key);
+ console.warn(
+ `Read-only folder ${dirPath}, leaving it untouched and deriving ids from paths`,
+ );
+};
+
+export const isWritable = async (dirPath: string): Promise => {
+ try {
+ await fs.access(dirPath, constants.W_OK);
+ return true;
+ } catch (error) {
+ return !isReadOnlyError(error);
+ }
+};
diff --git a/app/_server/actions/lib/stamp-uuid.ts b/app/_server/actions/lib/stamp-uuid.ts
index da9beb13f..1fec125c7 100644
--- a/app/_server/actions/lib/stamp-uuid.ts
+++ b/app/_server/actions/lib/stamp-uuid.ts
@@ -1,7 +1,13 @@
+import path from "path";
import fs from "fs/promises";
import { extractYamlMetadata, generateUuid } from "@/app/_utils/yaml-metadata-utils";
import { serverWriteFile } from "@/app/_server/actions/file";
import { singleFlight } from "@/app/_server/actions/lib/concurrency";
+import {
+ isWritable,
+ pathUuid,
+ warnReadOnly,
+} from "@/app/_server/actions/lib/read-only";
const FRONTMATTER_OPEN = /^(\uFEFF?---\r?\n)/;
const FRONTMATTER_BLOCK = /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/;
@@ -36,12 +42,21 @@ const _stampable = (content: string, filePath: string): boolean => {
return true;
};
+export const lacksUuid = (content: string): boolean =>
+ !UUID_LINE.test(content.match(FRONTMATTER_BLOCK)?.[0] || "");
+
export const stampUuid = (filePath: string): Promise =>
singleFlight(`stamp:${filePath}`, async () => {
try {
const content = await fs.readFile(filePath, "utf-8");
const existing = extractYamlMetadata(content).metadata.uuid;
if (typeof existing === "string" && existing) return existing;
+
+ if (!(await isWritable(path.dirname(filePath)))) {
+ warnReadOnly(path.dirname(filePath));
+ return pathUuid(filePath);
+ }
+
if (!_stampable(content, filePath)) return undefined;
const uuid = generateUuid();
diff --git a/app/_server/actions/migration/share-migration.ts b/app/_server/actions/migration/share-migration.ts
index fbd58e40d..fcfba3d00 100644
--- a/app/_server/actions/migration/share-migration.ts
+++ b/app/_server/actions/migration/share-migration.ts
@@ -13,11 +13,14 @@ import {
SHARED_WITH_KEY,
} from "@/app/_consts/sharing";
import { toSharedWith } from "@/app/_utils/sharing-utils";
+import { updateYamlMetadata } from "@/app/_utils/yaml-metadata-utils";
+import { serverWriteFile } from "@/app/_server/actions/file";
+import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid";
import {
- extractYamlMetadata,
- generateUuid,
- updateYamlMetadata,
-} from "@/app/_utils/yaml-metadata-utils";
+ isReadOnlyError,
+ isWritable,
+ warnReadOnly,
+} from "@/app/_server/actions/lib/read-only";
import { grepExtractFrontmatter } from "@/app/_utils/grep-utils";
import { isAdmin } from "@/app/_server/actions/users";
import { needsMigration } from "@/app/_server/actions/lib/migration-check";
@@ -100,22 +103,7 @@ const _mdNames = async (dirPath: string): Promise => {
const _stampItems = async (dirPath: string): Promise => {
for (const name of await _mdNames(dirPath)) {
- const filePath = path.join(dirPath, name);
-
- try {
- const content = await fs.readFile(filePath, "utf-8");
- const { metadata } = extractYamlMetadata(content);
-
- if (metadata.uuid) continue;
-
- await fs.writeFile(
- filePath,
- updateYamlMetadata(content, { uuid: generateUuid() }),
- "utf-8",
- );
- } catch (error) {
- console.error(`Failed to stamp uuid on ${filePath}:`, error);
- }
+ await stampUuid(path.join(dirPath, name));
}
};
@@ -211,12 +199,22 @@ const _stampTree = async (
isRoot: boolean,
log: MigrationLog,
): Promise => {
+ const subDirs = await _subDirs(dirPath);
+
+ if (!(await isWritable(dirPath))) {
+ warnReadOnly(dirPath);
+ log.changes.push(`Left read-only folder ${path.basename(dirPath)} untouched`);
+
+ for (const subDir of subDirs) {
+ await _stampTree(subDir, false, log);
+ }
+ return;
+ }
+
if (!isRoot) {
await catUuid(dirPath);
}
- const subDirs = await _subDirs(dirPath);
-
for (const subDir of subDirs) {
await catUuid(subDir);
}
@@ -268,11 +266,15 @@ const _applyShares = async (
users: Record,
): Promise => {
const content = await fs.readFile(filePath, "utf-8");
+ if (!content.trim()) {
+ throw new Error(`Refusing to write shares onto an empty read: ${filePath}`);
+ }
+
const updated = updateYamlMetadata(content, {
[SHARED_WITH_KEY]: toSharedWith(users),
});
- await fs.writeFile(filePath, updated, "utf-8");
+ await serverWriteFile(filePath, updated);
};
const _migrateShares = async (
@@ -311,6 +313,12 @@ const _migrateShares = async (
`Moved sharing into ${path.basename(filePath)} (${Object.keys(users).join(", ")})`,
);
} catch (error) {
+ if (isReadOnlyError(error)) {
+ warnReadOnly(path.dirname(filePath));
+ log.changes.push(`Skipped sharing on read-only ${path.basename(filePath)}`);
+ continue;
+ }
+
failures += 1;
console.error(`Failed to migrate shares for ${filePath}:`, error);
}
diff --git a/app/_server/actions/note/creator.ts b/app/_server/actions/note/creator.ts
index 71b162b4b..6c4f2b628 100644
--- a/app/_server/actions/note/creator.ts
+++ b/app/_server/actions/note/creator.ts
@@ -1,4 +1,5 @@
import path from "path";
+import { getTranslations } from "next-intl/server";
import { Note, SanitisedUser } from "@/app/_types";
import { Modes, PermissionTypes } from "@/app/_types/enums";
import { ensureDir, serverWriteFile } from "@/app/_server/actions/file";
@@ -22,6 +23,7 @@ import { commitNote } from "@/app/_server/actions/history";
import { targetDir, bouncer } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
import { noteToMarkdown } from "./parsers";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
export const makeNote = async (
actor: SanitisedUser,
@@ -41,6 +43,13 @@ export const makeNote = async (
const encrypted = isEncrypted(contentWithoutMetadata);
const target = await targetDir(Modes.NOTES, actor.username, category);
+
+ if (category && !target.isMount && target.category !== category) {
+ console.warn("Refusing note outside its owner's folder:", category);
+ const t = await getTranslations("errors");
+ return { error: t("categoryOutOfBounds") };
+ }
+
const content = encrypted
? contentWithoutMetadata
: await tidyItemLinks(contentWithoutMetadata, target.owner);
@@ -123,6 +132,6 @@ export const makeNote = async (
title || "unknown",
false,
);
- return { error: "Failed to create note" };
+ return { error: await failedWith(error, "Failed to create note") };
}
};
diff --git a/app/_server/actions/note/crud.ts b/app/_server/actions/note/crud.ts
index 24ca5a82a..7a9f98f03 100644
--- a/app/_server/actions/note/crud.ts
+++ b/app/_server/actions/note/crud.ts
@@ -1,62 +1,24 @@
"use server";
import path from "path";
-import { Note } from "@/app/_types";
import { generateUniqueFilename } from "@/app/_utils/filename-utils";
-import {
- detectEncryptionMethod,
- isEncrypted,
-} from "@/app/_utils/encryption-utils";
-import { getCurrentUser, getUsername } from "@/app/_server/actions/users";
-import {
- ensureDir,
- serverDeleteFile,
- serverWriteFile,
-} from "@/app/_server/actions/file";
+import { getCurrentUser } from "@/app/_server/actions/users";
+import { ensureDir, serverWriteFile } from "@/app/_server/actions/file";
import { revalidatePath } from "next/cache";
-import { NOTES_DIR } from "@/app/_consts/files";
import { PermissionTypes, Modes } from "@/app/_types/enums";
-import { sanitizeMarkdown } from "@/app/_utils/markdown-utils";
-import { extractHashtagsFromContent } from "@/app/_utils/tag-utils";
-import { getFormData } from "@/app/_utils/global-utils";
import { UNCATEGORIZED } from "@/app/_consts/notes";
-import {
- refreshWikilinks,
- tidyItemLinks,
-} from "@/app/_server/actions/relations/tidy";
import { canReach } from "@/app/_server/actions/share/queries";
import {
extractYamlMetadata as stripYaml,
generateUuid,
updateYamlMetadata,
- strayMeta,
- keptMeta,
} from "@/app/_utils/yaml-metadata-utils";
-import { getSettings } from "@/app/_server/actions/config";
-import { logContentEvent } from "@/app/_server/actions/log";
-import { commitNote } from "@/app/_server/actions/history";
-import { noteToMarkdown } from "./parsers";
import { getNoteById } from "./queries";
-import {
- targetDir,
- bouncer,
- shownAs,
- movePlan,
- refusalMessage,
-} from "@/app/_server/actions/share/target";
+import { targetDir, bouncer } from "@/app/_server/actions/share/target";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
import { claimedName } from "@/app/_server/actions/lib/actor";
import { makeNote } from "./creator";
-
-const _noteDirFor = (owner: string, category?: string): string =>
- path.join(process.cwd(), NOTES_DIR(owner), category || UNCATEGORIZED);
-
-const _namedIn = (user: unknown): string | undefined => {
- if (typeof user === "string") return user || undefined;
-
- const named = (user as { username?: unknown })?.username;
- return typeof named === "string" ? named : undefined;
-};
+import { dropNote, editNote } from "./editor";
export const createNote = async (formData: FormData) => {
const actor = await getCurrentUser();
@@ -78,342 +40,6 @@ export const createNote = async (formData: FormData) => {
return makeNote(actor, formData);
};
-export const updateNote = async (formData: FormData, autosaveNotes = false) => {
- try {
- const { title, content, category, user, uuid } = getFormData(formData, [
- "title",
- "content",
- "category",
- "user",
- "uuid",
- ]);
- const settings = await getSettings();
-
- const actingUsername = (await getUsername()) || _namedIn(user);
-
- if (!actingUsername) {
- return { error: "Not authenticated" };
- }
-
- const note = await getNoteById(uuid);
-
- if (!note) {
- throw new Error("Note not found");
- }
-
- const canEdit = await canReach(
- note.uuid!,
- "note",
- actingUsername,
- PermissionTypes.EDIT,
- );
-
- if (!canEdit) {
- return { error: "Permission denied" };
- }
-
- const shownSource = await shownAs(
- Modes.NOTES,
- actingUsername,
- note.owner!,
- note.category || "",
- );
-
- const shownCategory = category || shownSource;
- const { home, destination, target, isMoving } = await movePlan(
- Modes.NOTES,
- actingUsername,
- note,
- shownCategory,
- );
-
- if (isMoving) {
- const verdict = await bouncer(
- target,
- actingUsername,
- PermissionTypes.CREATE,
- );
-
- if (!verdict.allowed) {
- return { error: verdict.error };
- }
-
- const canRemove = await canReach(
- note.uuid!,
- "note",
- actingUsername,
- PermissionTypes.DELETE,
- );
-
- if (!canRemove) {
- return { error: await refusalMessage() };
- }
- }
-
- const sanitizedContent = sanitizeMarkdown(content);
- const { metadata: incomingMeta, contentWithoutMetadata } =
- stripYaml(sanitizedContent);
- const convertedContent = isEncrypted(contentWithoutMetadata)
- ? contentWithoutMetadata
- : refreshWikilinks(
- await tidyItemLinks(contentWithoutMetadata, note.owner || actingUsername),
- note.uuid,
- );
-
- const encryptionMethod =
- detectEncryptionMethod(convertedContent) || undefined;
-
- const extractedTags = extractHashtagsFromContent(convertedContent);
- const sortedTags = Array.from(new Set(extractedTags)).sort();
-
- const updatedDoc = {
- ...note,
- title,
- content: convertedContent,
- category: destination.category,
- owner: destination.owner,
- updatedAt: new Date().toISOString(),
- encrypted: isEncrypted(convertedContent),
- encryptionMethod,
- tags: sortedTags.length > 0 ? sortedTags : undefined,
- extraMetadata: keptMeta(note.extraMetadata, strayMeta(incomingMeta)),
- };
-
- const sourceDir = _noteDirFor(home.owner, home.category);
- const categoryDir = _noteDirFor(destination.owner, destination.category);
- await ensureDir(categoryDir);
-
- const currentId = note.id;
- let newFilename: string;
- let newId = currentId;
-
- if (title !== note.title) {
- const ownerUser = await getCurrentUser();
- const fileRenameMode = ownerUser?.fileRenameMode || "minimal";
- newFilename = await generateUniqueFilename(
- categoryDir,
- title,
- ".md",
- fileRenameMode,
- );
- newId = path.basename(newFilename, ".md");
- } else {
- newFilename = `${currentId}.md`;
- }
-
- if (newId !== currentId) {
- updatedDoc.id = newId;
- }
-
- const filePath = path.join(categoryDir, newFilename);
-
- const oldFilePath =
- isMoving || newId !== currentId
- ? path.join(sourceDir, `${currentId}.md`)
- : null;
-
- await serverWriteFile(filePath, noteToMarkdown(updatedDoc));
-
- if (!autosaveNotes && !updatedDoc.encrypted) {
- const historyRelativePath = path.join(
- updatedDoc.category || UNCATEGORIZED,
- `${newId}.md`,
- );
-
- const historyAction = isMoving ? "move" : "update";
-
- const historyMetadata = isMoving
- ? {
- oldCategory: home.category || UNCATEGORIZED,
- newCategory: updatedDoc.category || UNCATEGORIZED,
- oldPath: path.join(
- home.category || UNCATEGORIZED,
- `${currentId}.md`,
- ),
- }
- : undefined;
-
- commitNote(
- destination.owner,
- historyRelativePath,
- historyAction,
- title,
- historyMetadata,
- ).catch(() => {});
- }
-
- if (oldFilePath && oldFilePath !== filePath) {
- await serverDeleteFile(oldFilePath);
- }
-
- try {
- if (!autosaveNotes) {
- revalidatePath("/");
- revalidatePath(`/note/${note.uuid}`);
- }
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error,
- );
- }
-
- if (!updatedDoc.encrypted) {
- await logContentEvent(
- "note_updated",
- "note",
- note.uuid!,
- updatedDoc.title,
- true,
- { category: updatedDoc.category },
- );
- }
-
- await broadcast({
- type: "note",
- action: "updated",
- entityId: updatedDoc.uuid,
- username: actingUsername,
- });
-
- return {
- success: true,
- data: { ...updatedDoc, category: shownCategory },
- };
- } catch (error) {
- console.error("Error updating note:", error);
- const { title, uuid } = getFormData(formData, ["title", "uuid"]);
- await logContentEvent(
- "note_updated",
- "note",
- uuid!,
- title || "unknown",
- false,
- );
- return { error: "Failed to update note" };
- }
-};
-
-export const deleteNote = async (formData: FormData, username?: string) => {
- try {
- const { uuid } = getFormData(formData, ["uuid"]);
-
- let currentUser: any = null;
- if (username) {
- const { findUserRecord } =
- await import("@/app/_server/actions/users/records");
- const userResult = await findUserRecord(username);
- if (userResult) {
- currentUser = userResult;
- }
- }
-
- if (!currentUser) {
- currentUser = await getCurrentUser();
- }
-
- if (!currentUser) {
- return { error: "Not authenticated" };
- }
-
- const note = await getNoteById(uuid!);
-
- if (!note) {
- return { error: "Document not found" };
- }
-
- const canDelete = await canReach(
- note.uuid!,
- "note",
- currentUser.username,
- PermissionTypes.DELETE,
- );
-
- if (!canDelete) {
- return { error: "Permission denied" };
- }
-
- const ownerUsername = note.owner || currentUser.username;
- const source = await targetDir(
- Modes.NOTES,
- currentUser.username,
- note.category || "",
- );
-
- const verdict = await bouncer(
- source,
- currentUser.username,
- PermissionTypes.DELETE,
- );
-
- if (!verdict.allowed) {
- return { error: verdict.error };
- }
-
- const homeCategory = note.category || UNCATEGORIZED;
- const ownerDir = NOTES_DIR(ownerUsername);
- const filePath = path.join(ownerDir, homeCategory, `${note.id}.md`);
-
- await serverDeleteFile(filePath);
-
- if (!note.encrypted) {
- const deleteRelativePath = path.join(homeCategory, `${note.id}.md`);
- commitNote(
- ownerUsername,
- deleteRelativePath,
- "delete",
- note.title || note.id,
- ).catch(() => {});
- }
-
- try {
- revalidatePath("/");
- revalidatePath(`/note/${note.uuid}`);
- } catch (error) {
- console.warn(
- "Cache revalidation failed, but data was saved successfully:",
- error,
- );
- }
-
- await logContentEvent(
- "note_deleted",
- "note",
- note.uuid!,
- note.title!,
- true,
- { category: note.category },
- );
-
- await broadcast({
- type: "note",
- action: "deleted",
- entityId: note.uuid,
- username: currentUser.username,
- });
-
- return { success: true };
- } catch (error) {
- console.error("Error deleting note:", error);
-
- const { uuid } = getFormData(formData, ["uuid"]);
-
- let title = "unknown";
- try {
- const note = await getNoteById(uuid!);
- title = note?.title || "unknown";
- } catch (lookupError) {
- console.warn(
- "Failed to re-read note while logging deletion:",
- lookupError,
- );
- }
-
- await logContentEvent("note_deleted", "note", uuid!, title, false);
- return { error: "Failed to delete note" };
- }
-};
-
export const cloneNote = async (formData: FormData) => {
try {
const uuid = formData.get("uuid") as string;
@@ -523,3 +149,26 @@ export const cloneNote = async (formData: FormData) => {
return { error: "Failed to clone note" };
}
};
+
+export const updateNote = async (
+ formData: FormData,
+ autosaveNotes = false,
+) => {
+ const actor = await getCurrentUser();
+
+ if (!actor?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ return editNote(actor, formData, autosaveNotes);
+};
+
+export const deleteNote = async (formData: FormData) => {
+ const actor = await getCurrentUser();
+
+ if (!actor?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ return dropNote(actor, formData);
+};
diff --git a/app/_server/actions/note/editor.ts b/app/_server/actions/note/editor.ts
new file mode 100644
index 000000000..d3396677e
--- /dev/null
+++ b/app/_server/actions/note/editor.ts
@@ -0,0 +1,382 @@
+import path from "path";
+import { SanitisedUser } from "@/app/_types";
+import { generateUniqueFilename } from "@/app/_utils/filename-utils";
+import {
+ detectEncryptionMethod,
+ isEncrypted,
+} from "@/app/_utils/encryption-utils";
+import {
+ ensureDir,
+ serverDeleteFile,
+ serverWriteFile,
+} from "@/app/_server/actions/file";
+import { revalidatePath } from "next/cache";
+import { NOTES_DIR } from "@/app/_consts/files";
+import { PermissionTypes, Modes } from "@/app/_types/enums";
+import { sanitizeMarkdown } from "@/app/_utils/markdown-utils";
+import { extractHashtagsFromContent } from "@/app/_utils/tag-utils";
+import { getFormData } from "@/app/_utils/global-utils";
+import { UNCATEGORIZED } from "@/app/_consts/notes";
+import {
+ refreshWikilinks,
+ tidyItemLinks,
+} from "@/app/_server/actions/relations/tidy";
+import { canReach } from "@/app/_server/actions/share/queries";
+import {
+ extractYamlMetadata as stripYaml,
+ strayMeta,
+ keptMeta,
+} from "@/app/_utils/yaml-metadata-utils";
+import { logContentEvent } from "@/app/_server/actions/log";
+import { commitNote } from "@/app/_server/actions/history";
+import { noteToMarkdown } from "./parsers";
+import { getNoteById } from "./queries";
+import {
+ targetDir,
+ bouncer,
+ shownAs,
+ movePlan,
+ refusalMessage,
+} from "@/app/_server/actions/share/target";
+import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { failedWith } from "@/app/_server/actions/lib/read-only-message";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+
+const _noteDirFor = (owner: string, category?: string): string =>
+ path.join(process.cwd(), NOTES_DIR(owner), category || UNCATEGORIZED);
+
+const _editNote = async (
+ actor: SanitisedUser,
+ formData: FormData,
+ autosaveNotes: boolean,
+) => {
+ try {
+ const { title, content, category, uuid } = getFormData(formData, [
+ "title",
+ "content",
+ "category",
+ "uuid",
+ ]);
+
+ const actingUsername = actor.username;
+
+ if (!actingUsername) {
+ return { error: "Not authenticated" };
+ }
+
+ const note = await getNoteById(uuid);
+
+ if (!note) {
+ throw new Error("Note not found");
+ }
+
+ const canEdit = await canReach(
+ note.uuid!,
+ "note",
+ actingUsername,
+ PermissionTypes.EDIT,
+ );
+
+ if (!canEdit) {
+ return { error: "Permission denied" };
+ }
+
+ const shownSource = await shownAs(
+ Modes.NOTES,
+ actingUsername,
+ note.owner!,
+ note.category || "",
+ );
+
+ const shownCategory = category || shownSource;
+ const { home, destination, target, isMoving } = await movePlan(
+ Modes.NOTES,
+ actingUsername,
+ note,
+ shownCategory,
+ );
+
+ if (isMoving) {
+ const verdict = await bouncer(
+ target,
+ actingUsername,
+ PermissionTypes.CREATE,
+ );
+
+ if (!verdict.allowed) {
+ return { error: verdict.error };
+ }
+
+ const canRemove = await canReach(
+ note.uuid!,
+ "note",
+ actingUsername,
+ PermissionTypes.DELETE,
+ );
+
+ if (!canRemove) {
+ return { error: await refusalMessage() };
+ }
+ }
+
+ const sanitizedContent = sanitizeMarkdown(content);
+ const { metadata: incomingMeta, contentWithoutMetadata } =
+ stripYaml(sanitizedContent);
+ const convertedContent = isEncrypted(contentWithoutMetadata)
+ ? contentWithoutMetadata
+ : refreshWikilinks(
+ await tidyItemLinks(contentWithoutMetadata, note.owner || actingUsername),
+ note.uuid,
+ );
+
+ const encryptionMethod =
+ detectEncryptionMethod(convertedContent) || undefined;
+
+ const extractedTags = extractHashtagsFromContent(convertedContent);
+ const sortedTags = Array.from(new Set(extractedTags)).sort();
+
+ const updatedDoc = {
+ ...note,
+ title,
+ content: convertedContent,
+ category: destination.category,
+ owner: destination.owner,
+ updatedAt: new Date().toISOString(),
+ encrypted: isEncrypted(convertedContent),
+ encryptionMethod,
+ tags: sortedTags.length > 0 ? sortedTags : undefined,
+ extraMetadata: keptMeta(note.extraMetadata, strayMeta(incomingMeta)),
+ };
+
+ const sourceDir = _noteDirFor(home.owner, home.category);
+ const categoryDir = _noteDirFor(destination.owner, destination.category);
+ await ensureDir(categoryDir);
+
+ const currentId = note.id;
+ let newFilename: string;
+ let newId = currentId;
+
+ if (title !== note.title) {
+ const fileRenameMode = actor.fileRenameMode || "minimal";
+ newFilename = await generateUniqueFilename(
+ categoryDir,
+ title,
+ ".md",
+ fileRenameMode,
+ );
+ newId = path.basename(newFilename, ".md");
+ } else {
+ newFilename = `${currentId}.md`;
+ }
+
+ if (newId !== currentId) {
+ updatedDoc.id = newId;
+ }
+
+ const filePath = path.join(categoryDir, newFilename);
+
+ const oldFilePath =
+ isMoving || newId !== currentId
+ ? path.join(sourceDir, `${currentId}.md`)
+ : null;
+
+ await serverWriteFile(filePath, noteToMarkdown(updatedDoc));
+
+ if (!autosaveNotes && !updatedDoc.encrypted) {
+ const historyRelativePath = path.join(
+ updatedDoc.category || UNCATEGORIZED,
+ `${newId}.md`,
+ );
+
+ const historyAction = isMoving ? "move" : "update";
+
+ const historyMetadata = isMoving
+ ? {
+ oldCategory: home.category || UNCATEGORIZED,
+ newCategory: updatedDoc.category || UNCATEGORIZED,
+ oldPath: path.join(
+ home.category || UNCATEGORIZED,
+ `${currentId}.md`,
+ ),
+ }
+ : undefined;
+
+ commitNote(
+ destination.owner,
+ historyRelativePath,
+ historyAction,
+ title,
+ historyMetadata,
+ ).catch(() => {});
+ }
+
+ if (oldFilePath && oldFilePath !== filePath) {
+ await serverDeleteFile(oldFilePath);
+ }
+
+ try {
+ if (!autosaveNotes) {
+ revalidatePath("/");
+ revalidatePath(`/note/${note.uuid}`);
+ }
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error,
+ );
+ }
+
+ if (!updatedDoc.encrypted) {
+ await logContentEvent(
+ "note_updated",
+ "note",
+ note.uuid!,
+ updatedDoc.title,
+ true,
+ { category: updatedDoc.category },
+ );
+ }
+
+ await broadcast({
+ type: "note",
+ action: "updated",
+ entityId: updatedDoc.uuid,
+ username: actingUsername,
+ });
+
+ return {
+ success: true,
+ data: { ...updatedDoc, category: shownCategory },
+ };
+ } catch (error) {
+ console.error("Error updating note:", error);
+ const { title, uuid } = getFormData(formData, ["title", "uuid"]);
+ await logContentEvent(
+ "note_updated",
+ "note",
+ uuid!,
+ title || "unknown",
+ false,
+ );
+ return { error: await failedWith(error, "Failed to update note") };
+ }
+};
+
+export const editNote = async (
+ actor: SanitisedUser,
+ formData: FormData,
+ autosaveNotes = false,
+) =>
+ runQueued(itemLane(Modes.NOTES, formData.get("uuid") as string), () =>
+ _editNote(actor, formData, autosaveNotes),
+ );
+
+export const dropNote = async (
+ currentUser: SanitisedUser,
+ formData: FormData,
+): Promise<{ success?: boolean; error?: string }> => {
+ try {
+ const { uuid } = getFormData(formData, ["uuid"]);
+
+ if (!currentUser?.username) {
+ return { error: "Not authenticated" };
+ }
+
+ const note = await getNoteById(uuid!);
+
+ if (!note) {
+ return { error: "Document not found" };
+ }
+
+ const canDelete = await canReach(
+ note.uuid!,
+ "note",
+ currentUser.username,
+ PermissionTypes.DELETE,
+ );
+
+ if (!canDelete) {
+ return { error: "Permission denied" };
+ }
+
+ const ownerUsername = note.owner || currentUser.username;
+ const source = await targetDir(
+ Modes.NOTES,
+ currentUser.username,
+ note.category || "",
+ );
+
+ const verdict = await bouncer(
+ source,
+ currentUser.username,
+ PermissionTypes.DELETE,
+ );
+
+ if (!verdict.allowed) {
+ return { error: verdict.error };
+ }
+
+ const homeCategory = note.category || UNCATEGORIZED;
+ const ownerDir = NOTES_DIR(ownerUsername);
+ const filePath = path.join(ownerDir, homeCategory, `${note.id}.md`);
+
+ await serverDeleteFile(filePath);
+
+ if (!note.encrypted) {
+ const deleteRelativePath = path.join(homeCategory, `${note.id}.md`);
+ commitNote(
+ ownerUsername,
+ deleteRelativePath,
+ "delete",
+ note.title || note.id,
+ ).catch(() => {});
+ }
+
+ try {
+ revalidatePath("/");
+ revalidatePath(`/note/${note.uuid}`);
+ } catch (error) {
+ console.warn(
+ "Cache revalidation failed, but data was saved successfully:",
+ error,
+ );
+ }
+
+ await logContentEvent(
+ "note_deleted",
+ "note",
+ note.uuid!,
+ note.title!,
+ true,
+ { category: note.category },
+ );
+
+ await broadcast({
+ type: "note",
+ action: "deleted",
+ entityId: note.uuid,
+ username: currentUser.username,
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error deleting note:", error);
+
+ const { uuid } = getFormData(formData, ["uuid"]);
+
+ let title = "unknown";
+ try {
+ const note = await getNoteById(uuid!);
+ title = note?.title || "unknown";
+ } catch (lookupError) {
+ console.warn(
+ "Failed to re-read note while logging deletion:",
+ lookupError,
+ );
+ }
+
+ await logContentEvent("note_deleted", "note", uuid!, title, false);
+ return { error: await failedWith(error, "Failed to delete note") };
+ }
+};
+
diff --git a/app/_server/actions/note/index.ts b/app/_server/actions/note/index.ts
index bd4c8f914..8ed0af4d3 100644
--- a/app/_server/actions/note/index.ts
+++ b/app/_server/actions/note/index.ts
@@ -2,9 +2,4 @@ export { parseMarkdownNote, noteToMarkdown } from "./parsers";
export { readNotesRecursively } from "./readers";
export { CheckForNeedsMigration } from "./migration";
export { createNote, updateNote, deleteNote, cloneNote } from "./crud";
-export {
- getNoteById,
- getUserNotes,
- getAllNotes,
- getNotesForDisplay,
-} from "./queries";
+export { viewNote, getNotesForDisplay } from "./viewer";
diff --git a/app/_server/actions/note/queries.ts b/app/_server/actions/note/queries.ts
index 8f9ad299e..836f074ca 100644
--- a/app/_server/actions/note/queries.ts
+++ b/app/_server/actions/note/queries.ts
@@ -1,5 +1,3 @@
-"use server";
-
import path from "path";
import fs from "fs/promises";
import { Note, User, GetNotesOptions } from "@/app/_types";
@@ -357,15 +355,3 @@ export const getUserNotes = async (options: GetNotesOptions = {}) => {
return { success: false, error: "Failed to fetch notes" };
}
};
-
-export const getNotesForDisplay = async (
- filter?: { type: "category" | "tag"; value: string } | null,
- limit: number = 20,
- offset: number = 0,
-) => {
- return getUserNotes({
- filter: filter || undefined,
- limit,
- offset: filter ? offset : undefined,
- });
-};
diff --git a/app/_server/actions/note/readers.ts b/app/_server/actions/note/readers.ts
index 8b9d2e64a..81773d7c1 100644
--- a/app/_server/actions/note/readers.ts
+++ b/app/_server/actions/note/readers.ts
@@ -25,7 +25,7 @@ import { parseMarkdownNote } from "./parsers";
import { Note } from "@/app/_types";
import { promisify } from "util";
import { exec } from "child_process";
-import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid";
+import { lacksUuid, stampUuid } from "@/app/_server/actions/lib/stamp-uuid";
import { SHARED_WITH_KEY } from "@/app/_consts/sharing";
const execAsync = promisify(exec);
@@ -272,7 +272,7 @@ export const readNotesRecursively = async (
rawContent: content,
};
} else {
- return parseMarkdownNote(
+ const note = parseMarkdownNote(
content,
id,
categoryPath,
@@ -284,6 +284,8 @@ export const readNotesRecursively = async (
},
fileName,
);
+ if (!lacksUuid(content)) return note;
+ return { ...note, uuid: (await stampUuid(filePath)) || note.uuid };
}
}
} catch (e) {
diff --git a/app/_server/actions/note/viewer.ts b/app/_server/actions/note/viewer.ts
new file mode 100644
index 000000000..ad5f863d9
--- /dev/null
+++ b/app/_server/actions/note/viewer.ts
@@ -0,0 +1,38 @@
+"use server";
+
+import { Note } from "@/app/_types";
+import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
+import { sessionActor } from "@/app/_server/actions/lib/actor";
+import { canReach } from "@/app/_server/actions/share/queries";
+import { getNoteById, getUserNotes } from "./queries";
+
+export const viewNote = async (uuid: string): Promise => {
+ if (!uuid) return undefined;
+
+ const actor = await sessionActor();
+ if ("error" in actor) return undefined;
+
+ const seen = await getNoteById(uuid, actor.username);
+ if (seen) return seen;
+
+ const allowed = await canReach(
+ uuid,
+ ItemTypes.NOTE,
+ actor.username,
+ PermissionTypes.READ,
+ );
+
+ return allowed ? getNoteById(uuid) : undefined;
+};
+
+export const getNotesForDisplay = async (
+ filter?: { type: "category" | "tag"; value: string } | null,
+ limit: number = 20,
+ offset: number = 0,
+) => {
+ return getUserNotes({
+ filter: filter || undefined,
+ limit,
+ offset: filter ? offset : undefined,
+ });
+};
diff --git a/app/_server/actions/notifications/index.ts b/app/_server/actions/notifications/index.ts
index d2bf1bb23..e47414930 100644
--- a/app/_server/actions/notifications/index.ts
+++ b/app/_server/actions/notifications/index.ts
@@ -3,8 +3,8 @@
import { AppNotification, AppNotificationData } from "@/app/_types";
import { Modes, NotificationTargets } from "@/app/_types/enums";
import { getCurrentUser } from "@/app/_server/actions/users";
-import { getListById } from "@/app/_server/actions/checklist";
-import { getNoteById } from "@/app/_server/actions/note";
+import { getListById } from "@/app/_server/actions/checklist/queries";
+import { getNoteById } from "@/app/_server/actions/note/queries";
import { mountsFor } from "@/app/_server/actions/share/mounts";
import { notifyUser, readNotificationsForUser, writeNotificationsForUser } from "./internal";
diff --git a/app/_server/actions/relations/index.ts b/app/_server/actions/relations/index.ts
index 2d68c6fc6..c17de8745 100644
--- a/app/_server/actions/relations/index.ts
+++ b/app/_server/actions/relations/index.ts
@@ -9,7 +9,8 @@ import type { BrainGraph, ItemRelations } from "@/app/_types/relations";
import { backlinksFor, graphFor, visibleItems } from "./queries";
import { rebuildOwnerRelations, rebuildRelations } from "./indexer";
import { relationsDb } from "./store";
-import { getNoteById, updateNote } from "@/app/_server/actions/note";
+import { updateNote } from "@/app/_server/actions/note";
+import { getNoteById } from "@/app/_server/actions/note/queries";
import { canReach } from "@/app/_server/actions/share/queries";
import { ItemTypes, PermissionTypes } from "@/app/_types/enums";
import { itemHref } from "@/app/_utils/global-utils";
diff --git a/app/_server/actions/relations/indexer.ts b/app/_server/actions/relations/indexer.ts
index e66d622a1..3be773ea7 100644
--- a/app/_server/actions/relations/indexer.ts
+++ b/app/_server/actions/relations/indexer.ts
@@ -7,6 +7,7 @@ import { isUuid } from "@/app/_consts/identity";
import { extractYamlMetadata } from "@/app/_utils/yaml-metadata-utils";
import { isEncrypted } from "@/app/_utils/encryption-utils";
import { singleFlight } from "@/app/_server/actions/lib/concurrency";
+import { pathUuid } from "@/app/_server/actions/lib/read-only";
import { getAllFileStats } from "@/app/_server/actions/file";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
import { readLinks, titleKey } from "./parser";
@@ -131,7 +132,8 @@ export const indexItemFile = (
const absPath = path.resolve(filePath);
const { metadata, contentWithoutMetadata } = extractYamlMetadata(content);
- const uuid = typeof metadata.uuid === "string" ? metadata.uuid.toLowerCase() : "";
+ const uuid =
+ typeof metadata.uuid === "string" ? metadata.uuid.toLowerCase() : pathUuid(absPath);
if (!isUuid(uuid)) return null;
const title =
diff --git a/app/_server/actions/relations/queries.ts b/app/_server/actions/relations/queries.ts
index 1d306eba5..768431391 100644
--- a/app/_server/actions/relations/queries.ts
+++ b/app/_server/actions/relations/queries.ts
@@ -1,5 +1,5 @@
-import { getUserNotes } from "@/app/_server/actions/note";
-import { getUserChecklists } from "@/app/_server/actions/checklist";
+import { getUserNotes } from "@/app/_server/actions/note/queries";
+import { getUserChecklists } from "@/app/_server/actions/checklist/queries";
import { UNCATEGORIZED } from "@/app/_consts/notes";
import {
BrainEdgeKinds,
diff --git a/app/_server/actions/reminders/scanner.ts b/app/_server/actions/reminders/scanner.ts
index bdc23b401..920b5be4f 100644
--- a/app/_server/actions/reminders/scanner.ts
+++ b/app/_server/actions/reminders/scanner.ts
@@ -6,12 +6,16 @@ import { exec } from "child_process";
import { promisify } from "util";
import { Item } from "@/app/_types";
import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists";
-import { ChecklistsTypes, NotificationTargets } from "@/app/_types/enums";
+import { ChecklistsTypes, Modes, NotificationTargets } from "@/app/_types/enums";
import { parseMarkdown, listToMarkdown } from "@/app/_utils/checklist-utils";
+import { extractYamlMetadata } from "@/app/_utils/yaml-metadata-utils";
+import { serverWriteFile } from "@/app/_server/actions/file";
import { updateItem } from "@/app/_utils/item-tree-utils";
import { notifyUser } from "@/app/_server/actions/notifications/internal";
import { usersWithAccess } from "@/app/_server/actions/share/queries";
import { broadcast } from "@/app/_server/actions/ws/broadcast";
+import { itemLane, runQueued } from "@/app/_server/actions/lib/concurrency";
+import { grepExtractField } from "@/app/_utils/grep-utils";
const _execAsync = promisify(exec);
@@ -47,6 +51,95 @@ const _collectDueItems = (items: Item[], now: number): Item[] => {
return due;
};
+const _remindFile = async (
+ filePath: string,
+ checklistsRoot: string,
+ now: number,
+): Promise => {
+ const uuid = await grepExtractField(filePath, "uuid");
+ if (!uuid) {
+ console.warn(`[reminders] skipping list without uuid: ${filePath}`);
+ return;
+ }
+
+ await runQueued(itemLane(Modes.CHECKLISTS, uuid), async () => {
+ const relPath = path.relative(checklistsRoot, filePath);
+ const parts = relPath.split(path.sep);
+ if (parts.length < 2) return;
+
+ const owner = parts[0];
+ const filename = parts[parts.length - 1];
+ const listId = filename.replace(/\.md$/, "");
+ const category = parts.slice(1, -1).join("/") || "Uncategorized";
+
+ const content = await fs.readFile(filePath, "utf-8");
+ if (extractYamlMetadata(content).metadata.uuid !== uuid) return;
+
+ const stats = await fs.stat(filePath);
+ const list = parseMarkdown(
+ content,
+ listId,
+ category,
+ owner,
+ false,
+ { birthtime: stats.birthtime, mtime: stats.mtime },
+ filePath,
+ );
+
+ if (list.type !== ChecklistsTypes.KANBAN) return;
+
+ const dueItems = _collectDueItems(list.items, now);
+ if (dueItems.length === 0) return;
+
+ const sharees = await usersWithAccess(uuid);
+ const recipients = Array.from(new Set([owner, ...sharees]));
+
+ let updatedItems = list.items;
+ for (const item of dueItems) {
+ for (const username of recipients) {
+ await notifyUser(username, {
+ type: "reminder",
+ title: "",
+ message: "",
+ titleKey: "reminderTitle",
+ messageKey: "reminderMessage",
+ messageVars: {
+ task: item.text,
+ board: list.title,
+ },
+ data: {
+ itemId: list.uuid,
+ itemType: NotificationTargets.CHECKLIST,
+ taskId: item.id,
+ },
+ });
+ }
+
+ updatedItems = updateItem(updatedItems, item.id, (it) => ({
+ ...it,
+ reminder: it.reminder
+ ? { ...it.reminder, notified: true }
+ : undefined,
+ }));
+ }
+
+ const updatedList = {
+ ...list,
+ items: updatedItems,
+ updatedAt: new Date().toISOString(),
+ };
+
+ await serverWriteFile(filePath, listToMarkdown(updatedList));
+
+ await broadcast({
+ type: "checklist",
+ action: "updated",
+ entityId: updatedList.uuid,
+ username: owner,
+ });
+ });
+};
+
export const scanReminders = async (): Promise => {
if (_isRunning) return;
_isRunning = true;
@@ -71,85 +164,7 @@ export const scanReminders = async (): Promise => {
for (const filePath of files) {
try {
- const relPath = path.relative(checklistsRoot, filePath);
- const parts = relPath.split(path.sep);
- if (parts.length < 2) continue;
-
- const owner = parts[0];
- const filename = parts[parts.length - 1];
- const listId = filename.replace(/\.md$/, "");
- const category = parts.slice(1, -1).join("/") || "Uncategorized";
-
- const content = await fs.readFile(filePath, "utf-8");
- const stats = await fs.stat(filePath);
- const list = parseMarkdown(
- content,
- listId,
- category,
- owner,
- false,
- { birthtime: stats.birthtime, mtime: stats.mtime },
- filePath,
- );
-
- if (list.type !== ChecklistsTypes.KANBAN) continue;
-
- const dueItems = _collectDueItems(list.items, now);
- if (dueItems.length === 0) continue;
-
- if (!list.uuid) {
- console.warn(
- `[reminders] skipping list without uuid: ${filePath}`,
- );
- continue;
- }
-
- const sharees = await usersWithAccess(list.uuid);
- const recipients = Array.from(new Set([owner, ...sharees]));
-
- let updatedItems = list.items;
- for (const item of dueItems) {
- for (const username of recipients) {
- await notifyUser(username, {
- type: "reminder",
- title: "",
- message: "",
- titleKey: "reminderTitle",
- messageKey: "reminderMessage",
- messageVars: {
- task: item.text,
- board: list.title,
- },
- data: {
- itemId: list.uuid,
- itemType: NotificationTargets.CHECKLIST,
- taskId: item.id,
- },
- });
- }
-
- updatedItems = updateItem(updatedItems, item.id, (it) => ({
- ...it,
- reminder: it.reminder
- ? { ...it.reminder, notified: true }
- : undefined,
- }));
- }
-
- const updatedList = {
- ...list,
- items: updatedItems,
- updatedAt: new Date().toISOString(),
- };
-
- await fs.writeFile(filePath, listToMarkdown(updatedList), "utf-8");
-
- await broadcast({
- type: "checklist",
- action: "updated",
- entityId: updatedList.uuid,
- username: owner,
- });
+ await _remindFile(filePath, checklistsRoot, now);
} catch (err) {
console.error(`[reminders] failed to process ${filePath}:`, err);
}
diff --git a/app/_server/actions/share/category-info.ts b/app/_server/actions/share/category-info.ts
index 3f284a8ba..7175fe612 100644
--- a/app/_server/actions/share/category-info.ts
+++ b/app/_server/actions/share/category-info.ts
@@ -14,6 +14,12 @@ import {
grepExtractFrontmatter,
} from "@/app/_utils/grep-utils";
import { runQueued } from "@/app/_server/actions/lib/concurrency";
+import {
+ isReadOnlyError,
+ isWritable,
+ pathUuid,
+ warnReadOnly,
+} from "@/app/_server/actions/lib/read-only";
const _abs = (dirPath: string): string =>
path.isAbsolute(dirPath) ? dirPath : path.join(process.cwd(), dirPath);
@@ -72,7 +78,11 @@ export const writeCatInfo = async (
await fs.rename(tmpPath, finalPath);
return true;
} catch (error) {
- console.error(`Error writing ${CATEGORY_INFO_FILE} in ${dirPath}:`, error);
+ if (isReadOnlyError(error)) {
+ warnReadOnly(dirPath);
+ } else {
+ console.error(`Error writing ${CATEGORY_INFO_FILE} in ${dirPath}:`, error);
+ }
try {
await fs.unlink(tmpPath);
} catch {}
@@ -106,9 +116,14 @@ export const catUuid = async (dirPath: string): Promise