Files
TasGrid/src/store/notes.ts
T

366 lines
12 KiB
TypeScript

import { toast } from "solid-sonner";
import { pb } from "@/lib/pocketbase";
import { NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
import { buildNoteTag, normalizeNoteKey } from "@/lib/tags";
import { relationFilter } from "./contexts";
import {
activeNoteId,
setActiveNoteId,
type Note,
type Task,
setStore,
store,
unwrapRelationId
} from "./state";
import { getTaskListFields, mapRecordToTask, updateTask } from "./tasks";
let notesLoadPromise: Promise<void> | null = null;
let deferredNotesLoadTimer: number | undefined;
export const loadedNoteDetailIds = new Set<string>();
export const failedNoteDetailIds = new Set<string>();
export const mapRecordToNote = (r: any, includeContent = true): Note => {
return {
id: r.id,
title: r.title,
key: r.key || normalizeNoteKey(r.title),
content: includeContent ? r.content : undefined,
tags: Array.isArray(r.tags) && r.tags.length > 0 ? r.tags : [buildNoteTag(r.key || r.title)],
isPrivate: r.isPrivate || false,
user: unwrapRelationId(r.user) || "",
tasks: r.tasks || [],
created: r.created,
updated: r.updated,
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
};
};
const reconcileFetchedNotes = (records: any[], includeContent = false) => {
const existingById = new Map(store.notes.map(note => [note.id, note]));
const nextNotes = records.map((record: any) => {
const mapped = mapRecordToNote(record, includeContent);
const existing = existingById.get(mapped.id);
if (!includeContent && existing?.content !== undefined) {
mapped.content = existing.content;
}
return mapped;
});
setStore("notes", nextNotes);
};
export const fetchNoteById = async (id: string): Promise<Note | null> => {
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(id);
if (!record) return null;
return mapRecordToNote(record);
} catch (err) {
console.error("Failed to fetch note by ID:", err);
return null;
}
};
export const updateStoreWithNote = (note: Note) => {
failedNoteDetailIds.delete(note.id);
if (note.content !== undefined) {
loadedNoteDetailIds.add(note.id);
}
setStore("notes", currentNotes => {
const index = currentNotes.findIndex(n => n.id === note.id);
if (index !== -1) {
const newNotes = [...currentNotes];
newNotes[index] = note;
return newNotes;
}
return [...currentNotes, note];
});
};
export const loadNotesMetadata = async () => {
if (!pb.authStore.isValid) return;
if (store.hasLoadedNotesMetadata) return;
if (notesLoadPromise) return notesLoadPromise;
const currentUserId = pb.authStore.model?.id;
setStore("isNotesLoading", true);
notesLoadPromise = (async () => {
try {
const notesRecords = await pb.collection(NOTES_COLLECTION).getFullList({
filter: `isPrivate = false || ${relationFilter("user", currentUserId)}`,
sort: "-created",
fields: "id,created,updated,title,key,tags,isPrivate,user,tasks,deletedAt",
requestKey: null
});
reconcileFetchedNotes(notesRecords, false);
setStore("hasLoadedNotesMetadata", true);
} catch (notesErr) {
console.warn("Failed to load notes (collection might not exist yet):", notesErr);
} finally {
setStore("isNotesLoading", false);
notesLoadPromise = null;
}
})();
return notesLoadPromise;
};
export const scheduleDeferredNotesMetadataLoad = () => {
if (!pb.authStore.isValid || store.hasLoadedNotesMetadata || notesLoadPromise || deferredNotesLoadTimer) return;
deferredNotesLoadTimer = window.setTimeout(() => {
deferredNotesLoadTimer = undefined;
void loadNotesMetadata();
}, 0);
};
export const requestImmediateNotesMetadataLoad = async () => {
if (deferredNotesLoadTimer) {
clearTimeout(deferredNotesLoadTimer);
deferredNotesLoadTimer = undefined;
}
await loadNotesMetadata();
};
export const loadNoteContent = async (id: string) => {
if (!id) return;
if (loadedNoteDetailIds.has(id)) return;
if (failedNoteDetailIds.has(id)) return;
if (store.loadingNoteContentIds.includes(id)) return;
setStore("loadingNoteContentIds", ids => ids.includes(id) ? ids : [...ids, id]);
try {
const currentUserId = pb.authStore.model?.id;
const visibilityFilter = `id = "${id}" && (isPrivate = false || ${relationFilter("user", currentUserId)})`;
const record = await pb.collection(NOTES_COLLECTION).getFirstListItem(visibilityFilter, {
requestKey: `note-content-${id}`
});
const note = mapRecordToNote(record, true);
updateStoreWithNote(note);
loadedNoteDetailIds.add(id);
} catch (err) {
const status = (err as any)?.status;
if (status === 404) {
failedNoteDetailIds.add(id);
return;
}
console.error("Failed to load note content:", err);
} finally {
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
formData.append("attachments+", file);
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = record.content || "";
const filesToDelete: string[] = [];
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
if (!content.includes(filename) && !decodedContent.includes(filename)) {
filesToDelete.push(filename);
}
}
if (filesToDelete.length > 0) {
await pb.collection(NOTES_COLLECTION).update(noteId, {
"attachments-": filesToDelete
});
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
}
} catch (err) {
if ((err as any)?.status === 404) return;
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
}
};
const removeDeletedNoteFromStore = (id: string) => {
if (activeNoteId() === id) {
setActiveNoteId(null);
}
loadedNoteDetailIds.delete(id);
failedNoteDetailIds.delete(id);
setStore("loadingNoteContentIds", ids => ids.filter(existingId => existingId !== id));
setStore("notes", notes => notes.filter(note => note.id !== id));
};
const removeNoteReferencesFromTask = (task: Task, note: Note): Partial<Task> | null => {
const noteKey = (note.key || normalizeNoteKey(note.title)).toLowerCase();
const noteTag = buildNoteTag(note.key || note.title).toLowerCase();
const nextNoteRefs = task.noteRefs.filter(ref =>
ref.noteId !== note.id &&
ref.key.toLowerCase() !== noteKey
);
const nextShareRefs = task.shareRefs.filter(ref =>
!(ref.kind === "note" && (ref.contextId === note.id || ref.key.toLowerCase() === noteKey))
);
const nextTags = (task.tags || []).filter(tag => tag.toLowerCase() !== noteTag);
const noteRefsChanged = nextNoteRefs.length !== task.noteRefs.length;
const shareRefsChanged = nextShareRefs.length !== task.shareRefs.length;
const tagsChanged = nextTags.length !== (task.tags || []).length;
if (!noteRefsChanged && !shareRefsChanged && !tagsChanged) {
return null;
}
return {
tags: nextTags,
noteRefs: nextNoteRefs,
shareRefs: nextShareRefs
};
};
const cleanupTasksForPermanentlyDeletedNote = async (note: Note) => {
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: getTaskListFields(),
requestKey: null
});
const affectedTasks = taskRecords
.filter((record: any) => !record.tags?.includes("__template__"))
.map((record: any) => mapRecordToTask(record))
.filter(task => !!removeNoteReferencesFromTask(task, note));
for (const task of affectedTasks) {
const cleanup = removeNoteReferencesFromTask(task, note);
if (!cleanup) continue;
await updateTask(task.id, cleanup);
}
};
export const updateNote = async (id: string, updates: Partial<Note>) => {
if (!pb.authStore.isValid) return;
if (updates.content !== undefined) {
loadedNoteDetailIds.add(id);
}
const optimisticUpdates = {
...updates,
key: updates.key || (updates.title ? normalizeNoteKey(updates.title) : undefined),
updated: new Date().toISOString()
};
setStore("notes", n => n.id === id, optimisticUpdates);
try {
const pbUpdates: any = { ...updates };
if ("deletedAt" in updates) {
if (updates.deletedAt) {
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
} else {
pbUpdates.deletedAt = null;
}
}
if (!pbUpdates.key && updates.title) {
pbUpdates.key = normalizeNoteKey(updates.title);
}
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
} catch (err) {
console.error("Note update failed", err);
toast.error("Failed to update note.");
}
};
export const removeNote = (id: string) => {
updateNote(id, { deletedAt: Date.now() });
};
export const restoreNote = (id: string) => {
updateNote(id, { deletedAt: null });
};
export const deleteNotePermanently = async (id: string) => {
if (!pb.authStore.isValid) return;
const note = store.notes.find(existing => existing.id === id) || await fetchNoteById(id);
if (note) {
await cleanupTasksForPermanentlyDeletedNote(note);
}
removeDeletedNoteFromStore(id);
try {
await pb.collection(NOTES_COLLECTION).delete(id);
} catch (err) {
console.error("Note delete failed", err);
toast.error("Failed to delete note.");
}
};
export const loadTasksLinkedToNote = async (note: Pick<Note, "id" | "key" | "title" | "tasks">) => {
if (!pb.authStore.isValid || !note?.id) return;
const noteTag = buildNoteTag(note.key || note.title);
const escapedNoteTag = noteTag.replace(/"/g, '\\"');
try {
const listRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `(tags ~ "${escapedNoteTag}") && tags !~ "__template__"`,
sort: "-updated",
fields: getTaskListFields(),
requestKey: `note-linked-${note.id}`
}).catch(() => []);
const recordMap = new Map<string, any>();
listRecords.forEach(record => {
if (!record?.id) return;
if (record.tags?.includes("__template__")) return;
recordMap.set(record.id, record);
});
if (recordMap.size === 0) return;
setStore("tasks", currentTasks => {
const taskMap = new Map(currentTasks.map(task => [task.id, task]));
recordMap.forEach((record: any) => {
const mapped = mapRecordToTask(record);
const existing = taskMap.get(mapped.id);
if (existing?.content) {
mapped.content = existing.content;
}
taskMap.set(mapped.id, mapped);
});
return Array.from(taskMap.values());
});
} catch (err: any) {
console.error("Failed to load note-linked tasks:", err?.status, err?.response || err);
}
};