/** * TasGrid SDK * * Headless helpers for sister apps that need to create TasGrid tasks and notes * using the same normalized creation model as the current TasGrid app. */ const DEFAULT_COLLECTIONS = { tasks: "TasGrid", notes: "TasGrid_Notes", contexts: "TasGrid_Contexts", }; export const TASGRID_SDK_SIGNATURE = "context-model-v2-2026-03-26"; const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; const USER_CONTEXT_KEY_PREFIX = "user-"; const URGENCY_HOURS = { 10: 12, 9: 24, 8: 36, 7: 72, 6: 120, 5: 240, 4: 504, 3: 1080, 2: 2160, 1: 4320, }; const referenceCacheByPb = new WeakMap(); const normalizeWhitespace = (value) => String(value || "").trim().replace(/\s+/g, " "); const normalizeKey = (value) => normalizeWhitespace(value) .toLowerCase() .replace(/^[@#]+/, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); export const normalizeContextKey = (value) => normalizeKey(value); export const normalizeNoteKey = (value) => normalizeKey(value); export const buildShareTag = (displayName) => `@${normalizeWhitespace(displayName)}`; export const buildNoteTag = (keyOrTitle) => `#${normalizeWhitespace(keyOrTitle)}`; const buildUserContextKey = (userId) => `${USER_CONTEXT_KEY_PREFIX}${userId}`; const parseTag = (rawTag) => { const raw = normalizeWhitespace(rawTag); const prefix = raw[0]; const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw; const key = normalizeKey(display); if (prefix === "@") { return { kind: "share", raw, key, display }; } if (prefix === "#") { return { kind: "note", raw, key, display }; } return { kind: "label", raw, key, display }; }; const parseTags = (rawTags) => (Array.isArray(rawTags) ? rawTags : []) .map(parseTag) .filter((tag) => !!tag.display); const uniqueStrings = (values) => [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]; const dedupeShareRefs = (refs) => { const seen = new Set(); return (Array.isArray(refs) ? refs : []).filter((ref) => { if (!ref) return false; const key = `${ref.kind}:${ref.contextId}:${ref.key}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; const dedupeNoteRefs = (refs) => { const seen = new Set(); return (Array.isArray(refs) ? refs : []).filter((ref) => { if (!ref) return false; const key = `${ref.noteId}:${ref.key}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; const getSdkCache = (pb) => { let cache = referenceCacheByPb.get(pb); if (!cache) { cache = { collectionsKey: null, contexts: null, notes: null, updatedByFieldSupport: null, }; referenceCacheByPb.set(pb, cache); } return cache; }; const getCollections = (payload = {}) => ({ tasks: payload.collections?.tasks || DEFAULT_COLLECTIONS.tasks, notes: payload.collections?.notes || DEFAULT_COLLECTIONS.notes, contexts: payload.collections?.contexts || DEFAULT_COLLECTIONS.contexts, }); const resetCacheIfCollectionsChanged = (cache, collections) => { const key = JSON.stringify(collections); if (cache.collectionsKey !== key) { cache.collectionsKey = key; cache.contexts = null; cache.notes = null; cache.updatedByFieldSupport = null; } }; const getCurrentUserId = (pb) => pb?.authStore?.model?.id || null; const getCurrentUserDisplayName = (pb) => normalizeWhitespace(pb?.authStore?.model?.name || pb?.authStore?.model?.email || ""); const getRelationId = (value) => { if (!value) return null; if (typeof value === "string") return value; if (Array.isArray(value)) return value[0] || null; if (typeof value === "object" && typeof value.id === "string") return value.id; return null; }; const getDefaultSharePolicyByKind = (kind) => { if (kind === "user") return "collaborative"; if (kind === "note") return "collaborative"; return "handoff"; }; const mapContextRecord = (record) => ({ id: record.id, key: record.key || normalizeContextKey(record.displayName || record.name || ""), displayName: record.displayName || record.name || record.key || "", kind: record.kind || "bucket", policy: record.policy || (record.kind === "user" ? "collaborative" : "handoff"), targetUserId: getRelationId(record.targetUserId), deletedAt: record.deletedAt || null, }); const mapNoteRecord = (record) => ({ id: record.id, key: record.key || normalizeNoteKey(record.title || record.id), title: record.title || "Untitled", tags: uniqueStrings(record.tags), isPrivate: !!record.isPrivate, user: getRelationId(record.user) || "", }); const findContextForShareTag = (contexts, parsedTag) => contexts.find((context) => !context.deletedAt && context.key === parsedTag.key) || contexts.find((context) => !context.deletedAt && context.kind === "user" && normalizeContextKey(context.displayName) === parsedTag.key) || contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === parsedTag.key) || contexts.find((context) => !context.deletedAt && buildShareTag(context.displayName).toLowerCase() === parsedTag.raw.toLowerCase()) || null; const getContextByRef = (contexts, notes, ref) => (ref.kind === "note" ? (() => { const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key); return note ? { id: note.id, key: note.key, displayName: note.key || note.title, kind: "note", policy: getNoteSharePolicy(note), targetUserId: note.user, deletedAt: null, } : null; })() : null) || contexts.find((context) => !context.deletedAt && context.id === ref.contextId) || contexts.find((context) => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) || contexts.find((context) => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) || contexts.find((context) => !context.deletedAt && normalizeContextKey(context.displayName) === ref.key) || null; const getNoteByRef = (notes, ref) => notes.find((note) => note.id === ref.noteId) || notes.find((note) => note.key === ref.key) || null; const getNoteSharePolicy = (note) => note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; const getTaskSharePolicy = (contexts, notes, ref) => { if (ref.kind === "note") { if (ref.policy) return ref.policy; const note = notes.find((existing) => existing.id === ref.contextId || existing.key === ref.key); return getNoteSharePolicy(note); } const context = getContextByRef(contexts, notes, ref); if (ref.policy) return ref.policy; if (context?.policy) return context.policy; return getDefaultSharePolicyByKind(ref.kind); }; const getPersonalContextUserIdFromRef = (contexts, notes, ref) => { if (ref.kind !== "user") return null; const context = getContextByRef(contexts, notes, ref); if (context?.targetUserId) { return context.targetUserId; } if (ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) { return ref.key.replace(USER_CONTEXT_KEY_PREFIX, "") || null; } return null; }; const getTaskPersonalRefs = (contexts, notes, task) => (task.shareRefs || []).filter((ref) => ref.kind === "user" && !!getPersonalContextUserIdFromRef(contexts, notes, ref)); const taskHasLegacyPersonalAccess = (contexts, notes, task, userId) => task.ownerId === userId && getTaskPersonalRefs(contexts, notes, task).length === 0 && !(task.shareRefs || []).some((ref) => ref.kind !== "user" && getTaskSharePolicy(contexts, notes, ref) === "handoff"); const taskHasPersonalContextAccess = (contexts, notes, task, userId, allowLegacyFallback = true) => { const personalUserIds = [...new Set( (task.shareRefs || []) .filter((ref) => ref.kind === "user") .map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref)) .filter(Boolean) )]; return personalUserIds.includes(userId) || (allowLegacyFallback && taskHasLegacyPersonalAccess(contexts, notes, task, userId)); }; const buildTaskTags = (contexts, notes, labelTags, shareRefs, noteRefs, favoriteTag) => { const tags = [ ...(labelTags || []), ...(shareRefs || []).map((ref) => { const context = getContextByRef(contexts, notes, ref); if (ref.kind === "note") { return buildNoteTag(context?.key || ref.key); } if (context?.displayName) { return buildShareTag(context.displayName); } if (ref.kind === "user" && ref.key && ref.key.startsWith(USER_CONTEXT_KEY_PREFIX)) { return null; } return buildShareTag(ref.key); }), ...(noteRefs || []).map((ref) => { const note = getNoteByRef(notes, ref); return buildNoteTag(note?.key || ref.key); }), ]; if (favoriteTag && !tags.includes(favoriteTag)) { tags.push(favoriteTag); } return [...new Set(tags.filter(Boolean))]; }; const buildTaskShareRef = (contexts, notes, parsedTag, existingRefs = [], shareModeOverrides = {}) => { if (parsedTag.kind === "note") { const note = notes.find((existing) => existing.key === parsedTag.key); const nextRefBase = { contextId: note?.id || parsedTag.key, key: parsedTag.key, kind: "note", }; const existing = existingRefs.find((ref) => ref.kind === "note" && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key)); return { ...nextRefBase, policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase), }; } const context = findContextForShareTag(contexts, parsedTag); const nextRefBase = { contextId: context?.id || parsedTag.key, key: parsedTag.key, kind: context?.kind || "bucket", }; const existing = existingRefs.find((ref) => ref.kind === nextRefBase.kind && (ref.contextId === nextRefBase.contextId || ref.key === nextRefBase.key) ); return { ...nextRefBase, policy: shareModeOverrides[parsedTag.raw.toLowerCase()] || existing?.policy || getTaskSharePolicy(contexts, notes, nextRefBase), }; }; const deriveTaskRelationsFromTags = (contexts, notes, tags, existingShareRefs = [], shareModeOverrides = {}) => { const parsedTags = parseTags(tags); const labelTags = parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display); const shareRefs = parsedTags .filter((tag) => tag.kind === "share" || tag.kind === "note") .map((tag) => buildTaskShareRef(contexts, notes, tag, existingShareRefs, shareModeOverrides)); const noteRefs = parsedTags .filter((tag) => tag.kind === "note") .map((tag) => { const note = notes.find((existing) => existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key, }; }); return { labelTags, shareRefs: dedupeShareRefs(shareRefs), noteRefs: dedupeNoteRefs(noteRefs), }; }; const resolveMirroredOwnerId = (contexts, notes, currentOwnerId, shareRefs, preferredOwnerId) => { const personalUserIds = [...new Set( (shareRefs || []) .filter((ref) => ref.kind === "user") .map((ref) => getPersonalContextUserIdFromRef(contexts, notes, ref)) .filter(Boolean) )]; if (personalUserIds.length === 1) { return personalUserIds[0]; } if (personalUserIds.length > 1 && preferredOwnerId && personalUserIds.includes(preferredOwnerId)) { return preferredOwnerId; } return currentOwnerId || null; }; const applyHandoffs = (contexts, notes, senderUserId, senderPersonalContext, task, updates) => { if (!senderUserId || !taskHasPersonalContextAccess(contexts, notes, task, senderUserId)) { return updates; } const refs = updates.shareRefs || task.shareRefs || []; const stripSenderPersonalContext = (inputRefs) => senderPersonalContext ? inputRefs.filter((existingRef) => !( existingRef.kind === "user" && (existingRef.contextId === senderPersonalContext.id || existingRef.key === senderPersonalContext.key) )) : inputRefs; for (const ref of refs) { const context = getContextByRef(contexts, notes, ref); if (senderPersonalContext && ref.kind === "user" && ( ref.contextId === senderPersonalContext.id || ref.key === senderPersonalContext.key )) { continue; } if (!context || getTaskSharePolicy(contexts, notes, ref) !== "handoff") continue; const nextShareRefs = stripSenderPersonalContext(refs); const labelTags = updates.labelTags || task.labelTags || []; const noteRefs = updates.noteRefs || task.noteRefs || []; const favoriteTag = (updates.tags || task.tags || []).find((tag) => tag.endsWith("_favorite__")); if (context.kind === "user" && context.targetUserId) { return { ...updates, ownerId: context.targetUserId, shareRefs: nextShareRefs, tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag), }; } if (context.kind === "bucket" || context.kind === "note") { return { ...updates, shareRefs: nextShareRefs, tags: buildTaskTags(contexts, notes, labelTags, nextShareRefs, noteRefs, favoriteTag), }; } } return updates; }; const mapTaskRecord = (contexts, notes, record) => { const legacyTags = uniqueStrings(record.tags).filter((tag) => tag !== "__template__"); const parsedTags = parseTags(legacyTags); const shareRefs = Array.isArray(record.shareRefs) ? record.shareRefs.map((ref) => ({ contextId: ref.contextId, key: ref.key, kind: ref.kind, policy: ref.policy, })) : parsedTags .filter((tag) => tag.kind === "share" || tag.kind === "note") .map((tag) => buildTaskShareRef(contexts, notes, tag)); const noteRefs = Array.isArray(record.noteRefs) ? record.noteRefs.map((ref) => ({ noteId: ref.noteId, key: ref.key })) : parsedTags .filter((tag) => tag.kind === "note") .map((tag) => { const note = notes.find((existing) => existing.key === tag.key); return { noteId: note?.id || tag.key, key: tag.key, }; }); const noteShareRefs = noteRefs.map((ref) => ({ contextId: ref.noteId || ref.key, key: ref.key, kind: "note", policy: shareRefs.find((existing) => existing.kind === "note" && (existing.contextId === ref.noteId || existing.key === ref.key))?.policy, })); const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]); const labelTags = Array.isArray(record.labelTags) ? record.labelTags : parsedTags.filter((tag) => tag.kind === "label").map((tag) => tag.display); const favoriteTag = legacyTags.find((tag) => tag.endsWith("_favorite__")); return { id: record.id, title: record.title || "Untitled", tags: buildTaskTags(contexts, notes, labelTags, mergedShareRefs, noteRefs, favoriteTag), labelTags, shareRefs: mergedShareRefs, noteRefs, ownerId: getRelationId(record.user), createdBy: getRelationId(record.createdBy) || getRelationId(record.user), }; }; const loadContexts = async (pb, collections, cache) => { if (cache.contexts) return cache.contexts; const records = await pb.collection(collections.contexts).getFullList({ sort: "displayName,key", fields: "id,key,displayName,name,kind,policy,targetUserId,deletedAt", requestKey: null, }).catch(() => []); cache.contexts = records.map(mapContextRecord); return cache.contexts; }; const loadNotes = async (pb, collections, cache) => { if (cache.notes) return cache.notes; const records = await pb.collection(collections.notes).getFullList({ fields: "id,key,title,tags,isPrivate,user", requestKey: null, }).catch(() => []); cache.notes = records.map(mapNoteRecord); return cache.notes; }; const ensureOwnerPersonalContext = async (pb, ownerId, collections, cache) => { if (!ownerId) return null; const contexts = await loadContexts(pb, collections, cache); const existing = contexts.find((context) => !context.deletedAt && context.kind === "user" && context.targetUserId === ownerId ); if (existing) return existing; if (ownerId !== getCurrentUserId(pb)) { return null; } const displayName = getCurrentUserDisplayName(pb); if (!displayName) { return null; } try { const created = await pb.collection(collections.contexts).create({ key: buildUserContextKey(ownerId), displayName, kind: "user", policy: "collaborative", targetUserId: ownerId, }, { requestKey: null }); const mapped = mapContextRecord(created); cache.contexts = [...contexts, mapped]; return mapped; } catch (_err) { return null; } }; const detectUpdatedBySupport = async (pb, collectionName, cache) => { if (cache.updatedByFieldSupport !== null) { return cache.updatedByFieldSupport; } try { const sample = await pb.collection(collectionName).getList(1, 1, { fields: "id,updatedBy", requestKey: null, }); const item = sample?.items?.[0]; cache.updatedByFieldSupport = !!(item && Object.prototype.hasOwnProperty.call(item, "updatedBy")); } catch (_err) { cache.updatedByFieldSupport = false; } return cache.updatedByFieldSupport; }; const addOwnerContextTag = (tags, ownerContext) => { const nextTags = uniqueStrings(tags); if (!ownerContext?.displayName) return nextTags; const ownerTag = buildShareTag(ownerContext.displayName); if (!nextTags.some((tag) => tag.toLowerCase() === ownerTag.toLowerCase())) { nextTags.push(ownerTag); } return nextTags; }; const buildTaskCreatePayload = async (pb, payload = {}) => { const collections = getCollections(payload); const cache = getSdkCache(pb); resetCacheIfCollectionsChanged(cache, collections); const senderUserId = getCurrentUserId(pb); const ownerId = payload.owner || senderUserId; const ownerContext = await ensureOwnerPersonalContext(pb, ownerId, collections, cache); const senderPersonalContext = senderUserId ? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache) : null; const contexts = await loadContexts(pb, collections, cache); const notes = await loadNotes(pb, collections, cache); const urgencyVal = payload.urgency ?? 5; const startDate = payload.startDate || new Date().toISOString(); const dueDate = payload.dueDate || calculateDateFromUrgency(urgencyVal); const priority = payload.priority ?? 5; const size = payload.size ?? 3; const baseTags = Array.isArray(payload.tags) && payload.tags.length > 0 ? payload.tags : ["work"]; const tagsWithOwnerContext = addOwnerContextTag(baseTags, ownerContext); const content = payload.content || ""; const { labelTags, shareRefs, noteRefs } = deriveTaskRelationsFromTags( contexts, notes, tagsWithOwnerContext, [], payload.shareModeByTag || {} ); const initialTask = { title: payload.title || "New Task", tags: buildTaskTags(contexts, notes, labelTags, shareRefs, noteRefs), labelTags, shareRefs, noteRefs, ownerId: ownerId || "", createdBy: senderUserId || ownerId || "", }; const handoffUpdates = applyHandoffs( contexts, notes, senderUserId, senderPersonalContext, initialTask, initialTask ); const finalShareRefs = handoffUpdates.shareRefs || initialTask.shareRefs; const finalOwnerId = resolveMirroredOwnerId( contexts, notes, handoffUpdates.ownerId ?? initialTask.ownerId, finalShareRefs, handoffUpdates.ownerId ?? initialTask.ownerId ); const finalTask = { ...initialTask, ...handoffUpdates, ownerId: finalOwnerId, }; return { collections, cache, createPayload: { createdBy: finalTask.createdBy, title: finalTask.title, startDate, dueDate, priority, status: payload.status ?? 0, completed: payload.completed ?? false, tags: finalTask.tags, labelTags: finalTask.labelTags, shareRefs: finalTask.shareRefs, noteRefs: finalTask.noteRefs, content, size, }, }; }; /** * Calculates a future ISO date string based on the TasGrid urgency decay algorithm. * @param {number} level Urgency level (1-10) * @returns {string} ISO Date string */ export const calculateDateFromUrgency = (level) => { const roundedLevel = Math.max(1, Math.min(10, Math.round(level))); const hours = URGENCY_HOURS[roundedLevel] || 24; return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString(); }; /** * Creates a new task using TasGrid's current normalized creation model. * * @param {object} pb Authenticated PocketBase instance. * @param {object} payload Task payload. * @param {string} [payload.title="New Task"] Task title. * @param {string} [payload.owner] PocketBase user id whose personal context should own the task. * @param {number} [payload.priority=5] Priority (1-10). * @param {number} [payload.urgency=5] Urgency (1-10). Used when dueDate is omitted. * @param {number} [payload.size=3] Size (0-10). * @param {number} [payload.status=0] Status (0-10). * @param {boolean} [payload.completed=false] Completion status. * @param {string[]} [payload.tags=["work"]] Display tags. Supports labels, @contexts, and #notes. * @param {Record} [payload.shareModeByTag] Share mode overrides keyed by tag. * @param {string} [payload.content=""] HTML content. * @param {string} [payload.dueDate] ISO date string. * @param {string} [payload.startDate] ISO date string. * @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides. * @returns {Promise} Created PocketBase record. */ export const createTasgridTask = async (pb, payload = {}) => { if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) { throw new Error("Invalid or unauthenticated PocketBase instance provided."); } const { collections, cache, createPayload } = await buildTaskCreatePayload(pb, payload); const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache); if (updatedBySupported) { createPayload.updatedBy = getCurrentUserId(pb); } try { return await pb.collection(collections.tasks).create(createPayload, { requestKey: null }); } catch (err) { if (updatedBySupported && err?.data?.data?.updatedBy) { delete createPayload.updatedBy; cache.updatedByFieldSupport = false; return await pb.collection(collections.tasks).create(createPayload, { requestKey: null }); } console.error("TasGrid SDK: Failed to create task", err); throw err; } }; const buildNoteCreatePayload = async (pb, payload = {}) => { const collections = getCollections(payload); const cache = getSdkCache(pb); resetCacheIfCollectionsChanged(cache, collections); const title = payload.title || "New Note"; const explicitKey = normalizeNoteKey(payload.key || ""); const keyBase = explicitKey || normalizeNoteKey(title || "new-note") || "new-note"; const key = explicitKey || `${keyBase}-${Date.now()}`; const baseTags = uniqueStrings(payload.tags); const nextTags = payload.noteShareMode === "handoff" && !baseTags.includes(NOTE_HANDOFF_POLICY_TAG) ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags; return { collections, cache, noteData: { title, key, content: payload.content || "", tags: nextTags, isPrivate: !!payload.isPrivate, tasks: uniqueStrings(payload.tasks), user: payload.owner || getCurrentUserId(pb), }, }; }; /** * Adds current note-link metadata to existing tasks so they behave like note-linked * tasks created inside TasGrid. * * @param {object} pb Authenticated PocketBase instance. * @param {string|object} noteOrId Note id or note-like object with id/key/title/tags/user. * @param {string[]} taskIds Task ids to link. * @param {{collections?: {tasks?: string, notes?: string, contexts?: string}}} [options] Optional collection overrides. * @returns {Promise} Updated task records. */ export const linkTasgridTasksToNote = async (pb, noteOrId, taskIds, options = {}) => { if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) { throw new Error("Invalid or unauthenticated PocketBase instance provided."); } const collections = getCollections(options); const cache = getSdkCache(pb); resetCacheIfCollectionsChanged(cache, collections); const senderUserId = getCurrentUserId(pb); const senderPersonalContext = senderUserId ? await ensureOwnerPersonalContext(pb, senderUserId, collections, cache) : null; const contexts = await loadContexts(pb, collections, cache); const notes = await loadNotes(pb, collections, cache); const note = typeof noteOrId === "string" ? notes.find((existing) => existing.id === noteOrId) || mapNoteRecord(await pb.collection(collections.notes).getOne(noteOrId, { fields: "id,key,title,tags,isPrivate,user", requestKey: null, })) : { id: noteOrId.id, key: noteOrId.key || normalizeNoteKey(noteOrId.title || noteOrId.id), title: noteOrId.title || "Untitled", tags: uniqueStrings(noteOrId.tags), isPrivate: !!noteOrId.isPrivate, user: noteOrId.user || "", }; if (!notes.some((existing) => existing.id === note.id)) { cache.notes = [...notes, note]; } const noteTag = buildNoteTag(note.key || note.title); const uniqueTaskIds = uniqueStrings(taskIds); const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache); const updatedRecords = []; for (const taskId of uniqueTaskIds) { const record = await pb.collection(collections.tasks).getOne(taskId, { fields: "id,title,tags,labelTags,shareRefs,noteRefs,user,createdBy", requestKey: null, }); const currentTask = mapTaskRecord(contexts, cache.notes || notes, record); const favoriteTag = currentTask.tags.find((tag) => tag.endsWith("_favorite__")); const nextTags = uniqueStrings([...(currentTask.tags || []), noteTag]); const derived = deriveTaskRelationsFromTags( contexts, cache.notes || notes, nextTags, currentTask.shareRefs, {} ); const baseUpdates = { labelTags: derived.labelTags, shareRefs: derived.shareRefs, noteRefs: derived.noteRefs, tags: buildTaskTags(contexts, cache.notes || notes, derived.labelTags, derived.shareRefs, derived.noteRefs, favoriteTag), }; const handoffUpdates = applyHandoffs( contexts, cache.notes || notes, senderUserId, senderPersonalContext, currentTask, baseUpdates ); const finalShareRefs = handoffUpdates.shareRefs || baseUpdates.shareRefs; const finalOwnerId = resolveMirroredOwnerId( contexts, cache.notes || notes, handoffUpdates.ownerId ?? currentTask.ownerId, finalShareRefs, handoffUpdates.ownerId ?? currentTask.ownerId ); const updatePayload = { tags: handoffUpdates.tags || baseUpdates.tags, labelTags: handoffUpdates.labelTags || baseUpdates.labelTags, shareRefs: finalShareRefs, noteRefs: handoffUpdates.noteRefs || baseUpdates.noteRefs, }; if (updatedBySupported) { updatePayload.updatedBy = senderUserId; } try { const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null }); updatedRecords.push(updated); } catch (err) { if (updatedBySupported && err?.data?.data?.updatedBy) { delete updatePayload.updatedBy; cache.updatedByFieldSupport = false; const updated = await pb.collection(collections.tasks).update(taskId, updatePayload, { requestKey: null }); updatedRecords.push(updated); continue; } console.error("TasGrid SDK: Failed to link task to note", err); throw err; } } return updatedRecords; }; /** * Creates a new note using TasGrid's current creation model. * * @param {object} pb Authenticated PocketBase instance. * @param {object} payload Note payload. * @param {string} [payload.title="New Note"] Note title. * @param {string} [payload.key] Optional canonical note key override. * @param {string} [payload.content=""] HTML content. * @param {string[]} [payload.tags=[]] Note tags. * @param {"collaborative"|"handoff"} [payload.noteShareMode] Note workspace share mode. * @param {boolean} [payload.isPrivate=false] Privacy flag. * @param {string[]} [payload.tasks=[]] Task ids to store and link to this note. * @param {boolean} [payload.linkTasks=true] When tasks are provided, also apply task-side note linkage metadata. * @param {string} [payload.owner] PocketBase user id. Defaults to the current auth user. * @param {{tasks?: string, notes?: string, contexts?: string}} [payload.collections] Optional collection overrides. * @returns {Promise} Created PocketBase record. */ export const createTasgridNote = async (pb, payload = {}) => { if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) { throw new Error("Invalid or unauthenticated PocketBase instance provided."); } const { collections, cache, noteData } = await buildNoteCreatePayload(pb, payload); try { const record = await pb.collection(collections.notes).create(noteData, { requestKey: null }); const mappedNote = mapNoteRecord({ ...record, ...noteData }); cache.notes = [...(cache.notes || []), mappedNote]; if ((noteData.tasks || []).length > 0 && payload.linkTasks !== false) { await linkTasgridTasksToNote(pb, mappedNote, noteData.tasks, { collections }); } return record; } catch (err) { console.error("TasGrid SDK: Failed to create note", err); throw err; } };