src/store/index.ts breakup (no drama)
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user