From 6900a7cee1cda717b8905313d12326787a85a970 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Thu, 26 Mar 2026 11:39:52 -0500 Subject: [PATCH] SDK updates --- public/tasgrid-sdk.js | 870 +++++++++++++++++++++++++++++++++++++++--- test-embed.html | 435 ++++++++++++++++++--- 2 files changed, 1194 insertions(+), 111 deletions(-) diff --git a/public/tasgrid-sdk.js b/public/tasgrid-sdk.js index 3794ea7..f8fa51e 100644 --- a/public/tasgrid-sdk.js +++ b/public/tasgrid-sdk.js @@ -1,11 +1,21 @@ /** * TasGrid SDK - * - * A headless utility for creating tasks in the TasGrid PocketBase database - * from sister applications, ensuring all TasGrid defaults and date calculations - * are applied consistently. + * + * 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, @@ -16,7 +26,606 @@ const URGENCY_HOURS = { 4: 504, 3: 1080, 2: 2160, - 1: 4320 + 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, + }, + }; }; /** @@ -31,81 +640,228 @@ export const calculateDateFromUrgency = (level) => { }; /** - * Creates a new task in the TasGrid database using the provided PocketBase instance. - * - * @param {object} pb - An authenticated PocketBase JS SDK instance. - * @param {object} payload - The task payload. - * @param {string} [payload.title="New Task"] - The task title. - * @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id. - * @param {number} [payload.priority=5] - Priority (1-10). - * @param {number} [payload.urgency=5] - Urgency (1-10). Used to calculate dueDate if 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=[]] - Array of tag strings. - * @param {string} [payload.content=""] - HTML string for task description/notes. - * @param {string} [payload.dueDate] - Strict ISO Date string. Overrides urgency calculation. - * @param {string} [payload.startDate=""] - ISO Date string. - * @returns {Promise} The created PocketBase task record. + * 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 urgencyVal = payload.urgency ?? 5; - - const taskData = { - title: payload.title || "New Task", - priority: payload.priority ?? 5, - size: payload.size ?? 3, - status: payload.status ?? 0, - completed: payload.completed ?? false, - tags: payload.tags || [], - content: payload.content || "", - user: payload.owner || pb.authStore.model.id, - dueDate: payload.dueDate || calculateDateFromUrgency(urgencyVal), - startDate: payload.startDate || "", - }; + const { collections, cache, createPayload } = await buildTaskCreatePayload(pb, payload); + const updatedBySupported = await detectUpdatedBySupport(pb, collections.tasks, cache); + if (updatedBySupported) { + createPayload.updatedBy = getCurrentUserId(pb); + } try { - const record = await pb.collection('TasGrid').create(taskData); - return record; + 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), + }, + }; +}; + /** - * Creates a new note in the TasGrid database using the provided PocketBase instance. - * - * @param {object} pb - An authenticated PocketBase JS SDK instance. - * @param {object} payload - The note payload. - * @param {string} [payload.title="New Note"] - The note title. - * @param {string} [payload.content=""] - HTML string for note content. - * @param {string[]} [payload.tags=[]] - Array of tag strings. - * @param {boolean} [payload.isPrivate=false] - Whether the note is private. - * @param {string[]} [payload.tasks=[]] - Array of task IDs to link. - * @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id. - * @returns {Promise} The created PocketBase note record. + * 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 noteData = { - title: payload.title || "New Note", - content: payload.content || "", - tags: payload.tags || [], - isPrivate: payload.isPrivate || false, - tasks: payload.tasks || [], - user: payload.owner || pb.authStore.model.id, - }; + const { collections, cache, noteData } = await buildNoteCreatePayload(pb, payload); try { - const record = await pb.collection('TasGrid_Notes').create(noteData); + 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); diff --git a/test-embed.html b/test-embed.html index c4f8a53..0a2d011 100644 --- a/test-embed.html +++ b/test-embed.html @@ -105,6 +105,80 @@ h1 { margin-top: 0; } + + .sdk-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin-bottom: 10px; + } + + .sdk-card { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 14px; + background: #fafafa; + } + + .sdk-card h4 { + margin: 0 0 10px; + font-size: 14px; + } + + .sdk-card p { + margin: 0 0 10px; + font-size: 12px; + color: #666; + line-height: 1.5; + } + + .sdk-stack { + display: grid; + gap: 8px; + } + + .sdk-stack input, + .sdk-stack textarea, + .sdk-stack select { + width: 100%; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font: inherit; + box-sizing: border-box; + background: white; + } + + .sdk-stack textarea { + min-height: 74px; + resize: vertical; + } + + .sdk-inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + } + + .sdk-inline label { + font-size: 12px; + color: #444; + } + + .sdk-meta { + margin-top: 10px; + font-size: 12px; + color: #555; + line-height: 1.5; + } + + .sdk-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; + } @@ -148,22 +222,93 @@ -

Test Headless SDK (Centralized - Logic):

-
-
- - +

Test Headless SDK (Current + Creation Model):

+
+
+

Task Creation

+

Creates a task with normalized tags, structured sharing refs, and optional `#note` linkage, then verifies the task is anchored by personal-context refs instead of the legacy direct user link.

+
+ + + +
+ + + +
+ +
+
+ +
+
Last created task: none
-
- - + +
+

Note Creation

+

Creates a canonical-key note and can fully link tasks into the note workspace the new way.

+
+ + + + + +
+ + + +
+
+
+ + +
+
Last created note: none
+
+ +
+

Link Existing Tasks

+

Calls the SDK's direct note-link helper so you can test current workspace linking without creating a new note.

+
+ + +
+
+ + +
+
@@ -186,7 +331,9 @@

2. Programmatic Creation (TasGrid SDK):

The recommended way to create entries is using the tasgrid-sdk.js module. This - ensures all TasGrid-specific defaults and date logic (like urgency decay) are handled correctly. + now applies TasGrid's current normalized creation rules, including canonical note keys, + structured labelTags/shareRefs/noteRefs, owner-context + tagging, and note-linked task metadata.

Setup:

@@ -205,8 +352,12 @@ const task = await createTasgridTask(pb, { urgency: 10, // Automated: 10=Today, 5=Next Week, 1=Six Months priority: 8, // 1-10 size: 3, // Default: 3 (1 hour). Range 0-10. - tags: ["external"], - owner: "USER_ID" // Optional + tags: ["external", "@Shared Bucket", "#project-brief"], + shareModeByTag: { + "@shared bucket": "handoff", + "#project-brief": "collaborative" + }, + owner: "USER_ID" // Optional: target personal-context owner, not a direct task.user link });
@@ -220,8 +371,9 @@ const note = await createTasgridNote(pb, { title: "Project Brief", content: "<p>HTML content here...</p>", tags: ["documentation"], + noteShareMode: "collaborative", isPrivate: false, - tasks: [] // Optional: Array of Task IDs to link + tasks: [] // Optional: Task IDs to store and fully link into the note workspace });
@@ -278,6 +430,96 @@ function syncAuth(iframe) { const pb = new PocketBase('https://pocketbase.ccllc.pro'); const statusEl = document.getElementById('status'); let ENV_URL = 'https://tasgrid.ccllc.pro'; + let lastCreatedTaskId = ''; + let lastCreatedNoteId = ''; + + function setStatus(kind, message) { + statusEl.className = kind ? `status ${kind}` : 'status'; + statusEl.textContent = message; + statusEl.style.display = 'block'; + } + + function parseCsvInput(id) { + return (document.getElementById(id)?.value || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + } + + function parseShareModeOverrides(id) { + const lines = (document.getElementById(id)?.value || '') + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + const result = {}; + + for (const line of lines) { + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) continue; + const key = line.slice(0, separatorIndex).trim().toLowerCase(); + const value = line.slice(separatorIndex + 1).trim().toLowerCase(); + if (!key || (value !== 'collaborative' && value !== 'handoff')) continue; + result[key] = value; + } + + return result; + } + + function refreshSdkMeta() { + document.getElementById('sdk-task-meta').textContent = lastCreatedTaskId + ? `Last created task: ${lastCreatedTaskId}` + : 'Last created task: none'; + document.getElementById('sdk-note-meta').textContent = lastCreatedNoteId + ? `Last created note: ${lastCreatedNoteId}` + : 'Last created note: none'; + document.getElementById('sdk-link-meta').textContent = [ + lastCreatedTaskId ? `task ${lastCreatedTaskId}` : null, + lastCreatedNoteId ? `note ${lastCreatedNoteId}` : null + ].filter(Boolean).join(' | ') || 'Ready to link existing records.'; + } + + async function loadSdkModule() { + return await import(`${ENV_URL}/tasgrid-sdk.js`); + } + + function fillLinkingFields() { + if (lastCreatedTaskId && !document.getElementById('sdk-note-task-ids').value.trim()) { + document.getElementById('sdk-note-task-ids').value = lastCreatedTaskId; + } + if (lastCreatedTaskId && !document.getElementById('sdk-link-task-ids').value.trim()) { + document.getElementById('sdk-link-task-ids').value = lastCreatedTaskId; + } + if (lastCreatedNoteId && !document.getElementById('sdk-link-note-id').value.trim()) { + document.getElementById('sdk-link-note-id').value = lastCreatedNoteId; + } + } + + function loadLastCreatedNote() { + if (!lastCreatedNoteId) { + alert('Create a note first.'); + return; + } + document.getElementById('note-id-input').value = lastCreatedNoteId; + loadSpecificNote(); + } + + async function inspectTaskContextState(taskId) { + const record = await pb.collection('TasGrid').getOne(taskId, { + fields: 'id,user,shareRefs,noteRefs,tags,labelTags', + requestKey: null + }); + + const legacyUser = record.user && (typeof record.user === 'string' ? record.user : record.user.id); + const shareRefCount = Array.isArray(record.shareRefs) ? record.shareRefs.length : 0; + const noteRefCount = Array.isArray(record.noteRefs) ? record.noteRefs.length : 0; + + return { + legacyUser: legacyUser || null, + shareRefCount, + noteRefCount, + tags: Array.isArray(record.tags) ? record.tags : [] + }; + } function updateEnv() { const selected = document.querySelector('input[name="envToggle"]:checked').value; @@ -305,7 +547,7 @@ function syncAuth(iframe) { console.log(`Iframe ${iframe.id} loaded, checking auth sync...`); if (pb.authStore.isValid) { console.log(`Syncing existing auth to ${iframe.id}`); - sendAuthToIframes(); + sendAuth(pb.authStore.token, pb.authStore.model); } } @@ -326,26 +568,34 @@ function syncAuth(iframe) { return; } - statusEl.className = 'status'; - statusEl.textContent = 'Importing SDK and creating task...'; - statusEl.style.display = 'block'; + setStatus('', 'Importing SDK and creating task...'); try { - const { createTasgridTask } = await import(`${ENV_URL}/tasgrid-sdk.js`); + const { createTasgridTask, TASGRID_SDK_SIGNATURE } = await loadSdkModule(); + const priority = parseInt(document.getElementById('sdk-task-priority').value || '8', 10); + const urgency = parseInt(document.getElementById('sdk-task-urgency').value || '4', 10); + const size = parseInt(document.getElementById('sdk-task-size').value || '3', 10); + const tags = parseCsvInput('sdk-task-tags'); + const shareModeByTag = parseShareModeOverrides('sdk-task-share-modes'); const record = await createTasgridTask(pb, { title: title, - priority: 8, - urgency: 4, - tags: ["sdk_test"], - content: "This task was created by a sister app using the imported Headless SDK!" + priority: Number.isFinite(priority) ? priority : 8, + urgency: Number.isFinite(urgency) ? urgency : 4, + size: Number.isFinite(size) ? size : 3, + tags: tags, + shareModeByTag, + content: document.getElementById('sdk-task-content').value || "" }); - statusEl.className = 'status success'; - statusEl.textContent = `Task created successfully via SDK (ID: ${record.id})!`; + lastCreatedTaskId = record.id; + fillLinkingFields(); + refreshSdkMeta(); + const inspection = await inspectTaskContextState(record.id); + const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link'; + setStatus('success', `Task created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`); } catch (err) { - statusEl.className = 'status error'; - statusEl.textContent = 'Failed to create task via SDK: ' + err.message; + setStatus('error', 'Failed to create task via SDK: ' + err.message); } } @@ -356,25 +606,107 @@ function syncAuth(iframe) { return; } - statusEl.className = 'status'; - statusEl.textContent = 'Importing SDK and creating note...'; - statusEl.style.display = 'block'; + setStatus('', 'Importing SDK and creating note...'); try { - const { createTasgridNote } = await import(`${ENV_URL}/tasgrid-sdk.js`); + const { createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule(); + const taskIds = parseCsvInput('sdk-note-task-ids'); const record = await createTasgridNote(pb, { title: title, - content: "

This note was created headlessly via the SDK!

", - tags: ["sdk_test"], - isPrivate: false + key: document.getElementById('sdk-note-key').value.trim() || undefined, + content: document.getElementById('sdk-note-content').value || "", + tags: parseCsvInput('sdk-note-tags'), + noteShareMode: document.getElementById('sdk-note-share-mode').value, + isPrivate: document.getElementById('sdk-note-private').value === 'true', + tasks: taskIds, + linkTasks: document.getElementById('sdk-note-link-tasks').checked }); - statusEl.className = 'status success'; - statusEl.textContent = `Note created successfully via SDK (ID: ${record.id})!`; + lastCreatedNoteId = record.id; + document.getElementById('note-id-input').value = record.id; + fillLinkingFields(); + refreshSdkMeta(); + loadSpecificNote(); + setStatus('success', `Note created successfully via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'} (ID: ${record.id}). The notes iframe was pointed at the new note.`); } catch (err) { - statusEl.className = 'status error'; - statusEl.textContent = 'Failed to create note via SDK: ' + err.message; + setStatus('error', 'Failed to create note via SDK: ' + err.message); + } + } + + async function linkExistingTasksViaSDK() { + const noteId = document.getElementById('sdk-link-note-id').value.trim(); + const taskIds = parseCsvInput('sdk-link-task-ids'); + if (!pb.authStore.isValid) { + alert("Please log in first before linking tasks via SDK."); + return; + } + if (!noteId || taskIds.length === 0) { + alert("Provide both a target note ID and at least one task ID."); + return; + } + + setStatus('', 'Importing SDK and linking tasks to note...'); + + try { + const { linkTasgridTasksToNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule(); + const records = await linkTasgridTasksToNote(pb, noteId, taskIds); + lastCreatedNoteId = noteId; + fillLinkingFields(); + refreshSdkMeta(); + const inspection = await inspectTaskContextState(taskIds[0]); + setStatus('success', `Linked ${records.length} task(s) to note ${noteId} via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. First task now has shareRefs=${inspection.shareRefCount} and noteRefs=${inspection.noteRefCount}.`); + } catch (err) { + setStatus('error', 'Failed to link tasks via SDK: ' + err.message); + } + } + + async function createLinkedWorkspaceScenario() { + if (!pb.authStore.isValid) { + alert("Please log in first before creating linked workspace data."); + return; + } + + setStatus('', 'Creating a task and linked note scenario via the SDK...'); + + try { + const { createTasgridTask, createTasgridNote, TASGRID_SDK_SIGNATURE } = await loadSdkModule(); + const timestamp = new Date().toISOString().replace(/[^\d]/g, '').slice(0, 12); + const workspaceTag = `#sdk-workspace-${timestamp}`; + const taskRecord = await createTasgridTask(pb, { + title: `SDK Workspace Task ${timestamp}`, + priority: 8, + urgency: 4, + size: 3, + tags: ['sdk_test', workspaceTag], + shareModeByTag: { + [workspaceTag.toLowerCase()]: 'collaborative' + }, + content: 'Workspace task created by the test simulator.' + }); + + const noteRecord = await createTasgridNote(pb, { + title: `SDK Workspace Note ${timestamp}`, + tags: ['sdk_test'], + content: `

Workspace note for task ${taskRecord.id}.

`, + noteShareMode: 'collaborative', + isPrivate: false, + tasks: [taskRecord.id], + linkTasks: true + }); + + lastCreatedTaskId = taskRecord.id; + lastCreatedNoteId = noteRecord.id; + document.getElementById('note-id-input').value = noteRecord.id; + document.getElementById('sdk-note-task-ids').value = taskRecord.id; + fillLinkingFields(); + refreshSdkMeta(); + loadSpecificNote(); + const inspection = await inspectTaskContextState(taskRecord.id); + const legacyText = inspection.legacyUser ? `legacy user mirror still present: ${inspection.legacyUser}` : 'no legacy task.user link'; + setStatus('success', `Created linked workspace scenario via SDK ${TASGRID_SDK_SIGNATURE || '(unknown signature)'}. Task ${taskRecord.id} and note ${noteRecord.id} were created, loaded into the embed, and verified with shareRefs=${inspection.shareRefCount}, noteRefs=${inspection.noteRefCount}, ${legacyText}.`); + } catch (err) { + setStatus('error', 'Failed to create linked workspace scenario: ' + err.message); } } @@ -382,21 +714,17 @@ function syncAuth(iframe) { const email = document.getElementById('email').value; const password = document.getElementById('password').value; - statusEl.className = 'status'; - statusEl.textContent = 'Logging in...'; - statusEl.style.display = 'block'; + setStatus('', 'Logging in...'); try { const authData = await pb.collection('users').authWithPassword(email, password); - statusEl.className = 'status success'; - statusEl.textContent = `Logged in as ${authData.record.email}! Syncing iframes...`; + setStatus('success', `Logged in as ${authData.record.email}! Syncing iframes...`); // Automatically send auth to iframes sendAuth(pb.authStore.token, authData.record); } catch (err) { - statusEl.className = 'status error'; - statusEl.textContent = 'Login failed: ' + err.message; + setStatus('error', 'Login failed: ' + err.message); } } @@ -428,12 +756,11 @@ function syncAuth(iframe) { window.addEventListener('load', () => { if (pb.authStore.isValid && pb.authStore.model) { document.getElementById('email').value = pb.authStore.model.email || ""; - statusEl.className = 'status success'; - statusEl.textContent = 'Session restored. Click login or send manual token to sync.'; - statusEl.style.display = 'block'; + setStatus('success', 'Session restored. Click login or send manual token to sync.'); } + refreshSdkMeta(); }); - \ No newline at end of file +