366 lines
12 KiB
TypeScript
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);
|
|
}
|
|
};
|