diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker.tsx index ef6cecd..823a338 100644 --- a/src/components/TagPicker.tsx +++ b/src/components/TagPicker.tsx @@ -1,5 +1,5 @@ import { type Component, createSignal, For, createEffect } from "solid-js"; -import { store, upsertTagDefinition, isCurrentUserContextTag, setContextPolicy } from "@/store"; +import { store, upsertTagDefinition, isCurrentUserContextTag, setContextPolicy, setNoteContextPolicy, getNoteSharePolicy } from "@/store"; import { useTheme } from "./ThemeProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; @@ -55,6 +55,14 @@ export const TagPicker: Component = (props) => { ) || null; }; + const selectedNoteContext = () => { + const name = selectedTagName().trim(); + if (!name.startsWith("#")) return null; + + const key = normalizeNoteKey(name); + return store.notes.find(note => !note.deletedAt && note.key === key) || null; + }; + // When a tag name is selected, update the value score to match its current definition createEffect(() => { const name = selectedTagName().trim(); @@ -72,7 +80,12 @@ export const TagPicker: Component = (props) => { setShareMode(context.policy); return; } - if (selectedTagName().trim().startsWith("@")) { + const note = selectedNoteContext(); + if (note) { + setShareMode(getNoteSharePolicy(note)); + return; + } + if (selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) { setShareMode("handoff"); } }); @@ -82,6 +95,7 @@ export const TagPicker: Component = (props) => { if (!name) return; const context = selectedShareContext(); + const noteContext = selectedNoteContext(); if (name.startsWith("@")) { const normalized = normalizeContextKey(name); @@ -108,6 +122,11 @@ export const TagPicker: Component = (props) => { toast.error("Create the canonical note first before linking it with a #tag."); return; } + + if (noteContext && getNoteSharePolicy(noteContext) !== shareMode()) { + const updated = await setNoteContextPolicy(noteContext.id, shareMode()); + if (!updated) return; + } } // update global definition @@ -167,7 +186,7 @@ export const TagPicker: Component = (props) => { - {selectedTagName().trim().startsWith("@") && ( + {(selectedTagName().trim().startsWith("@") || selectedTagName().trim().startsWith("#")) && (
diff --git a/src/store/index.ts b/src/store/index.ts index 9aee378..9365c04 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -359,7 +359,7 @@ export interface ShareContext { id: string; key: string; displayName: string; - kind: 'user' | 'bucket'; + kind: 'user' | 'bucket' | 'note'; policy: 'collaborative' | 'handoff'; targetUserId?: string | null; supervisorUserIds?: string[]; @@ -372,7 +372,7 @@ export interface ShareContext { export interface TaskShareRef { contextId: string; key: string; - kind: 'user' | 'bucket'; + kind: 'user' | 'bucket' | 'note'; } export interface NoteRef { @@ -529,6 +529,12 @@ const findContextForShareTag = (tag: { key: string; raw: string }) => null; const getContextByRef = (ref: TaskShareRef) => + (ref.kind === "note" + ? (() => { + const note = store.notes.find(existing => existing.id === ref.contextId || existing.key === ref.key); + return note ? buildNoteContext(note) : null; + })() + : null) || store.contexts.find(context => !context.deletedAt && context.id === ref.contextId) || store.contexts.find(context => !context.deletedAt && context.key === ref.key && context.kind === ref.kind) || store.contexts.find(context => !context.deletedAt && context.kind === ref.kind && normalizeContextKey(context.displayName) === ref.key) || @@ -539,6 +545,21 @@ const getNoteByRef = (ref: NoteRef) => store.notes.find(note => note.id === ref.noteId) || store.notes.find(note => note.key === ref.key); +const NOTE_HANDOFF_POLICY_TAG = "__note_policy_handoff__"; + +export const getNoteSharePolicy = (note: Pick | null | undefined): "collaborative" | "handoff" => + note?.tags?.includes(NOTE_HANDOFF_POLICY_TAG) ? "handoff" : "collaborative"; + +const buildNoteContext = (note: Note): ShareContext => ({ + id: note.id, + key: note.key, + displayName: note.key || note.title, + kind: "note", + policy: getNoteSharePolicy(note), + targetUserId: note.user, + deletedAt: note.deletedAt ?? null +}); + const buildTaskTags = ( labelTags: string[], shareRefs: TaskShareRef[], @@ -549,6 +570,9 @@ const buildTaskTags = ( ...labelTags, ...shareRefs.map(ref => { const context = getContextByRef(ref); + if (ref.kind === "note") { + return buildNoteTag(context?.key || ref.key); + } if (context?.displayName) { return buildShareTag(context.displayName); } @@ -1069,8 +1093,16 @@ const mapRecordToTask = (r: any): Task => { const shareRefs: TaskShareRef[] = Array.isArray(r.shareRefs) ? r.shareRefs : parsedTags - .filter(tag => tag.kind === "share") + .filter(tag => tag.kind === "share" || tag.kind === "note") .map(tag => { + if (tag.kind === "note") { + const note = store.notes.find(existing => existing.key === tag.key); + return { + contextId: note?.id || tag.key, + key: tag.key, + kind: "note" + }; + } const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, @@ -1089,11 +1121,17 @@ const mapRecordToTask = (r: any): Task => { key: tag.key }; }); + const noteShareRefs: TaskShareRef[] = noteRefs.map(ref => ({ + contextId: ref.noteId || ref.key, + key: ref.key, + kind: "note" + })); + const mergedShareRefs = dedupeShareRefs([...shareRefs, ...noteShareRefs]); const labelTags: string[] = Array.isArray(r.labelTags) ? r.labelTags : parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); const favoriteTag = legacyTags.find((tag: string) => tag.endsWith("_favorite__")); - const tags = buildTaskTags(labelTags, shareRefs, noteRefs, favoriteTag); + const tags = buildTaskTags(labelTags, mergedShareRefs, noteRefs, favoriteTag); return { id: r.id, title: r.title, @@ -1104,7 +1142,7 @@ const mapRecordToTask = (r: any): Task => { completed: r.status === 10 || r.completed, tags: tags, labelTags, - shareRefs, + shareRefs: mergedShareRefs, noteRefs, ownerId: unwrapRelationId(r.user), createdBy: unwrapRelationId(r.createdBy) || unwrapRelationId(r.user), @@ -2368,6 +2406,29 @@ export const setContextPolicy = async (contextId: string, policy: "collaborative } }; +export const setNoteContextPolicy = async (noteId: string, policy: "collaborative" | "handoff") => { + if (!pb.authStore.isValid || !noteId) return false; + + const note = store.notes.find(existing => existing.id === noteId); + if (!note) return false; + + const baseTags = (note.tags || []).filter(tag => tag !== NOTE_HANDOFF_POLICY_TAG); + const nextTags = policy === "handoff" ? [...baseTags, NOTE_HANDOFF_POLICY_TAG] : baseTags; + const previousTags = note.tags || []; + + setStore("notes", n => n.id === noteId, "tags", nextTags); + + try { + await pb.collection(NOTES_COLLECTION).update(noteId, { tags: nextTags }, { requestKey: null }); + return true; + } catch (err) { + console.error("Failed to update note context policy:", err); + setStore("notes", n => n.id === noteId, "tags", previousTags); + toast.error("Failed to update note sharing mode"); + return false; + } +}; + export const savePersonalContextSupervisors = async (supervisorUserIds: string[]) => { const userId = pb.authStore.model?.id; if (!userId) return; @@ -2420,8 +2481,16 @@ export const addTask = async (title: string, options?: { dueDate?: Date | string const parsedTags = parseTags(tags); const labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); const shareRefs = parsedTags - .filter(tag => tag.kind === "share") + .filter(tag => tag.kind === "share" || tag.kind === "note") .map(tag => { + if (tag.kind === "note") { + const note = store.notes.find(existing => existing.key === tag.key); + return { + contextId: note?.id || tag.key, + key: tag.key, + kind: "note" + } satisfies TaskShareRef; + } const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key, @@ -2614,8 +2683,16 @@ export const updateTask = async (id: string, updates: Partial) => { const parsedTags = parseTags(finalUpdates.tags); finalUpdates.labelTags = parsedTags.filter(tag => tag.kind === "label").map(tag => tag.display); finalUpdates.shareRefs = parsedTags - .filter(tag => tag.kind === "share") + .filter(tag => tag.kind === "share" || tag.kind === "note") .map(tag => { + if (tag.kind === "note") { + const note = store.notes.find(existing => existing.key === tag.key); + return { + contextId: note?.id || tag.key, + key: tag.key, + kind: "note" + } satisfies TaskShareRef; + } const context = findContextForShareTag(tag); return { contextId: context?.id || tag.key,