Merge pull request #132 from xarmian/fix/open-bugs-batch-585-586-588-589-590

fix: resolve five open bugs (BUG-585, 586, 588, 589, 590)
This commit is contained in:
xarmian
2026-04-17 00:41:45 -04:00
committed by GitHub
11 changed files with 472 additions and 56 deletions
+9
View File
@@ -22,6 +22,7 @@ type User struct {
StripeCustomerID string `json:"-"` // Never serialized
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request
CreatedAt time.Time `json:"created_at"`
@@ -55,6 +56,14 @@ func (u *User) HasOAuthProvider(provider string) bool {
return false
}
// HasPassword returns true if the user has explicitly set a password that
// they can sign in with. OAuth-only users have a random placeholder hash
// stored in PasswordHash which can't actually be used to log in, so this
// bit is tracked separately from PasswordHash being non-empty.
func (u *User) HasPassword() bool {
return u.PasswordSet
}
// UserCreate is the input for registering a new user.
type UserCreate struct {
Email string `json:"email"`
+6 -9
View File
@@ -385,14 +385,11 @@ func (s *Server) handleOAuthUnlink(w http.ResponseWriter, r *http.Request) {
return
}
// Ensure user won't be locked out: they must have another linked
// provider remaining. All users have a password hash (OAuth users get
// a random one), so we can't distinguish "has usable password" from
// "has unusable random hash". Requiring another provider is the safe
// default. Users who set a real password via the reset flow can unlink
// their last provider since they'll still have password-based login.
// TODO: track whether the user has explicitly set a password to allow
// unlinking the last provider in that case.
// Ensure user won't be locked out after unlinking. They must retain
// at least one usable sign-in method: either another linked OAuth
// provider, or an explicitly-set password. OAuth-only users have a
// random placeholder hash in password_hash that can't actually be
// used to log in, which is why we track password_set separately.
providers := user.GetOAuthProviders()
hasOtherProvider := false
for _, p := range providers {
@@ -401,7 +398,7 @@ func (s *Server) handleOAuthUnlink(w http.ResponseWriter, r *http.Request) {
break
}
}
if !hasOtherProvider {
if !hasOtherProvider && !user.HasPassword() {
writeError(w, http.StatusBadRequest, "bad_request",
"Cannot unlink your only sign-in method. Link another provider or set a password first.")
return
@@ -0,0 +1,13 @@
-- Track whether the user has explicitly set a password (vs. the random
-- placeholder hash given to OAuth users in CreateOAuthUser). Used by the
-- OAuth unlink flow to decide whether the user will still have a way to
-- sign in after removing their last linked provider.
ALTER TABLE users ADD COLUMN password_set INTEGER NOT NULL DEFAULT 0;
-- Backfill: any user with no linked OAuth providers must have registered
-- via email/password, so they have a usable password.
UPDATE users
SET password_set = 1
WHERE oauth_providers IS NULL
OR oauth_providers = ''
OR oauth_providers = '[]';
@@ -0,0 +1,13 @@
-- Track whether the user has explicitly set a password (vs. the random
-- placeholder hash given to OAuth users in CreateOAuthUser). Used by the
-- OAuth unlink flow to decide whether the user will still have a way to
-- sign in after removing their last linked provider.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_set BOOLEAN NOT NULL DEFAULT FALSE;
-- Backfill: any user with no linked OAuth providers must have registered
-- via email/password, so they have a usable password.
UPDATE users
SET password_set = TRUE
WHERE oauth_providers IS NULL
OR oauth_providers = ''
OR oauth_providers = '[]';
+7
View File
@@ -442,6 +442,13 @@ func (s *Store) Search(params SearchParams) (*SearchResponse, error) {
total = len(results)
}
// Normalize nil → empty slice so JSON always serializes `results` as
// `[]` not `null`. Frontend consumers (CommandPalette) read .length
// without a null check.
if results == nil {
results = []SearchResult{}
}
return &SearchResponse{Results: results, Total: total, Limit: params.Limit, Offset: params.Offset, Facets: facets}, nil
}
+21 -4
View File
@@ -21,7 +21,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`)
const bcryptCost = 12
// user SELECT columns — used by all user queries.
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, disabled_at, last_active_at, created_at, updated_at`
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, last_active_at, created_at, updated_at`
// scanUser scans a user row into a User struct.
// Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after
@@ -35,6 +35,7 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error)
&u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
&u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes,
&u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders,
&u.PasswordSet,
&disabledAt, &lastActiveAt, &createdAt, &updatedAt,
)
if disabledAt.Valid {
@@ -84,9 +85,9 @@ func (s *Store) CreateUser(input models.UserCreate) (*models.User, error) {
ts := now()
_, err = s.db.Exec(s.q(`
INSERT INTO users (id, email, username, name, password_hash, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, ts, ts)
INSERT INTO users (id, email, username, name, password_hash, role, password_set, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, strings.ToLower(strings.TrimSpace(input.Email)), strings.TrimSpace(input.Username), strings.TrimSpace(input.Name), string(hash), role, true, ts, ts)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
@@ -155,6 +156,10 @@ func (s *Store) UpdateUser(id string, input models.UserUpdate) (*models.User, er
}
sets = append(sets, "password_hash = ?")
args = append(args, string(hash))
// Explicit password change — mark the user as having a usable password
// (clears the OAuth placeholder-hash state set by CreateOAuthUser).
sets = append(sets, "password_set = ?")
args = append(args, true)
}
if input.AvatarURL != nil {
sets = append(sets, "avatar_url = ?")
@@ -197,6 +202,18 @@ func (s *Store) ValidatePassword(email, password string) (*models.User, error) {
return nil, nil // wrong password — not an error
}
// A successful bcrypt compare with a user-supplied plaintext proves the
// stored hash is usable for real sign-ins (the random 64-byte placeholder
// set by CreateOAuthUser cannot be guessed). Auto-upgrade password_set so
// users who pre-date the password_set column — or who linked OAuth after
// signing up with email/password — don't get trapped in the OAuth-unlink
// check. Failure here is non-fatal: login succeeds regardless.
if !u.PasswordSet {
if _, err := s.db.Exec(s.q(`UPDATE users SET password_set = ? WHERE id = ?`), true, u.ID); err == nil {
u.PasswordSet = true
}
}
return u, nil
}
+177 -8
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { page } from '$app/state';
import { Editor, mergeAttributes } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
@@ -8,6 +10,7 @@
import Link from '@tiptap/extension-link';
import CodeBlock from '@tiptap/extension-code-block';
import Placeholder from '@tiptap/extension-placeholder';
import { copyToClipboard } from '$lib/utils/clipboard';
// Serialized mermaid render queue — mermaid can't handle concurrent renders
let mermaidMod: typeof import('mermaid') | null = null;
@@ -40,21 +43,101 @@
});
}
// Build a hover-to-reveal "Copy" button for a code block.
// Reads the live code text from `codeEl` so it copies edits too.
function buildCopyButton(codeEl: HTMLElement): HTMLButtonElement {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'code-copy-btn';
btn.setAttribute('contenteditable', 'false');
btn.setAttribute('aria-label', 'Copy code');
btn.title = 'Copy';
btn.textContent = 'Copy';
// mousedown + preventDefault avoids stealing focus / clobbering the selection
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const text = codeEl.textContent ?? '';
const ok = await copyToClipboard(text);
const prev = btn.textContent;
btn.textContent = ok ? 'Copied' : 'Failed';
btn.classList.toggle('copied', ok);
setTimeout(() => {
btn.textContent = prev;
btn.classList.remove('copied');
}, 1200);
});
return btn;
}
// ProseMirror plugin: when the user copies/cuts a selection that lives
// entirely inside a single code_block node, write the raw code text to the
// clipboard instead of letting tiptap-markdown wrap it in ``` fences.
const codeBlockCopyPlugin = new Plugin({
props: {
handleDOMEvents: {
copy: (view, event) => writeCodeBlockClipboard(view, event as ClipboardEvent, false),
cut: (view, event) => writeCodeBlockClipboard(view, event as ClipboardEvent, true),
},
},
});
function writeCodeBlockClipboard(view: any, event: ClipboardEvent, isCut: boolean): boolean {
const { state } = view;
const { from, to, empty } = state.selection;
if (empty) return false;
// Find the nearest code_block ancestor of the selection's from position.
const resolvedFrom = state.doc.resolve(from);
let codeBlockDepth = -1;
for (let d = resolvedFrom.depth; d >= 0; d--) {
if (resolvedFrom.node(d).type.name === 'codeBlock') {
codeBlockDepth = d;
break;
}
}
if (codeBlockDepth < 0) return false;
// Selection must be entirely within that same code block.
const blockStart = resolvedFrom.start(codeBlockDepth);
const blockEnd = resolvedFrom.end(codeBlockDepth);
if (from < blockStart || to > blockEnd) return false;
const text = state.doc.textBetween(from, to, '\n');
if (!event.clipboardData) return false;
event.preventDefault();
event.clipboardData.setData('text/plain', text);
// Clearing HTML prevents tiptap-markdown from re-decorating the paste target.
event.clipboardData.setData('text/html', '');
if (isCut) {
const tr = state.tr.delete(from, to);
view.dispatch(tr);
}
return true;
}
// CodeBlock with inline mermaid rendering via NodeView.
// Key: ignoreMutation prevents ProseMirror's MutationObserver from
// detecting our SVG insertion and triggering an infinite re-parse loop.
const MermaidCodeBlock = CodeBlock.extend({
addProseMirrorPlugins() {
return [codeBlockCopyPlugin];
},
addNodeView() {
return (({ node }: any) => {
const lang = node.attrs.language;
// Non-mermaid: default rendering
// Non-mermaid: default rendering + hover Copy button
if (lang !== 'mermaid') {
const pre = document.createElement('pre');
pre.classList.add('code-block');
const code = document.createElement('code');
if (lang) code.classList.add(`language-${lang}`);
pre.appendChild(code);
pre.appendChild(buildCopyButton(code));
return { dom: pre, contentDOM: code };
}
@@ -123,7 +206,9 @@
});
import { Markdown } from 'tiptap-markdown';
import { unescapeDocLinks } from '$lib/utils/markdown';
import { formatItemRef, itemUrlId, type Item } from '$lib/types';
import { collectionStore } from '$lib/stores/collections.svelte';
import { workspaceStore } from '$lib/stores/workspace.svelte';
import { BlockDragHandle } from './block-drag-handle';
import { SLASH_ITEMS } from './block-types';
@@ -205,14 +290,52 @@
const items = collectionStore.items ?? [];
if (!linkQuery) return items.slice(0, 10);
const q = linkQuery.toLowerCase();
return items.filter(d => d.title.toLowerCase().includes(q)).slice(0, 10);
return items
.filter(d => {
if (d.title.toLowerCase().includes(q)) return true;
// Match on the issue ref (e.g. DOC-535) and its numeric part
const ref = (formatItemRef(d) ?? '').toLowerCase();
if (ref && ref.includes(q)) return true;
return false;
})
.slice(0, 10);
}
function execLink(title: string) {
function execLink(doc: Item) {
if (!editor) return;
// Build the URL in the same shape wikiLinksToMarkdown produces so the
// save round-trip (markdownToWikiLinks) reliably converts it back to
// [[Title]]. We read from the live route params (not workspaceStore)
// because that's what the slug page uses when converting wiki-links —
// keeping the two in sync is what lets the round-trip work.
const routeUsername = page.params.username ?? '';
const routeWorkspace = page.params.workspace ?? workspaceStore.current?.slug ?? '';
const collSlug = doc.collection_slug ?? '';
const idSeg = itemUrlId(doc);
const prefix = routeUsername ? `/${routeUsername}/${routeWorkspace}` : `/${routeWorkspace}`;
const href = collSlug && idSeg && routeWorkspace ? `${prefix}/${collSlug}/${idSeg}` : '';
// Delete the [[ and any query text typed so far
const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from: linkStartPos, to }).insertContent(`[[${title}]]`).run();
const chain = editor.chain().focus().deleteRange({ from: linkStartPos, to });
if (href) {
// Insert the title as a real Tiptap link mark so it's clickable
// immediately (no reload needed). On save, tiptap-markdown emits
// [Title](href), which markdownToWikiLinks converts back to [[Title]].
chain.insertContent([
{
type: 'text',
text: doc.title,
marks: [{ type: 'link', attrs: { href } }],
},
// Trailing space drops the link mark so subsequent typing is plain text.
{ type: 'text', text: ' ' },
]).run();
} else {
// Fall back to [[Title]] text if we can't resolve a URL.
chain.insertContent(`[[${doc.title}]]`).run();
}
closeLink();
}
@@ -327,7 +450,7 @@
const items = getFilteredLinks();
if (event.key === 'ArrowDown') { event.preventDefault(); linkIdx = (linkIdx + 1) % Math.max(items.length, 1); return true; }
if (event.key === 'ArrowUp') { event.preventDefault(); linkIdx = (linkIdx - 1 + items.length) % Math.max(items.length, 1); return true; }
if (event.key === 'Enter') { event.preventDefault(); if (items[linkIdx]) execLink(items[linkIdx].title); return true; }
if (event.key === 'Enter') { event.preventDefault(); if (items[linkIdx]) execLink(items[linkIdx]); return true; }
if (event.key === 'Escape') { event.preventDefault(); closeLink(); return true; }
return false;
}
@@ -579,14 +702,17 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="none" style="position:fixed; inset:0; z-index:49;" onclick={closeLink}></div>
<div class="slash-menu" style:left="{linkX}px" style:top="{linkY}px">
{#each getFilteredLinks() as doc, i (doc.title)}
{#each getFilteredLinks() as doc, i (doc.id)}
<button
class="slash-item"
class:selected={i === linkIdx}
onmouseenter={() => linkIdx = i}
onclick={() => execLink(doc.title)}
onclick={() => execLink(doc)}
>
<span class="slash-icon">{doc.collection_icon ?? '📄'}</span>
{#if formatItemRef(doc)}
<span class="slash-ref">{formatItemRef(doc)}</span>
{/if}
<span class="slash-title">{doc.title}</span>
</button>
{:else}
@@ -907,7 +1033,50 @@
width: 24px; text-align: center; font-weight: 600; font-family: var(--font-mono);
font-size: 0.85em; color: var(--text-secondary);
}
.slash-title { font-weight: 500; }
.slash-title { font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slash-ref {
font-family: var(--font-mono);
font-size: 0.75em;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 1px 6px;
border-radius: 4px;
flex-shrink: 0;
}
/* Hover-to-reveal copy button on code blocks inside the editor */
.editor-wrapper :global(pre.code-block) {
position: relative;
}
.editor-wrapper :global(pre.code-block .code-copy-btn) {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 8px;
font-size: 0.75em;
font-family: var(--font-sans, inherit);
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 120ms ease, color 120ms ease, border-color 120ms ease;
user-select: none;
}
.editor-wrapper :global(pre.code-block:hover .code-copy-btn),
.editor-wrapper :global(pre.code-block .code-copy-btn:focus-visible) {
opacity: 1;
}
.editor-wrapper :global(pre.code-block .code-copy-btn:hover) {
color: var(--text-primary);
border-color: var(--text-secondary);
}
.editor-wrapper :global(pre.code-block .code-copy-btn.copied) {
color: var(--accent, #10b981);
border-color: var(--accent, #10b981);
opacity: 1;
}
/* Table toolbar */
.table-toolbar {
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { goto } from '$app/navigation';
let {
editor,
@@ -106,6 +107,27 @@
}
}
// Navigate in the current tab. Uses SvelteKit's goto() for internal
// (path-relative) URLs so the transition is a SPA navigation; external
// URLs fall back to a full-page load. The handler is attached to a
// real <a> element so middle-click / cmd-click / "copy link" behave
// naturally.
function handleHrefClick(e: MouseEvent) {
if (!href) return;
// Let the browser handle new-tab modifiers and middle-click itself.
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const target = href;
visible = false;
editing = false;
if (target.startsWith('/') && !target.startsWith('//')) {
goto(target);
} else {
window.location.assign(target);
}
}
function startEdit() {
editValue = href;
editing = true;
@@ -179,7 +201,13 @@
{#if !editing}
<div class="link-display">
<span class="link-href" title={href}>{truncatedHref}</span>
<a
class="link-href"
href={href}
title="Open link in this tab {href}"
onclick={handleHrefClick}
onmousedown={(e) => e.stopPropagation()}
>{truncatedHref}</a>
<div class="link-actions">
<button class="link-btn" onclick={openUrl} title="Open link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -273,6 +301,10 @@
}
.link-href {
display: inline-block;
text-decoration: none;
cursor: pointer;
font-size: 0.82em;
color: var(--text-muted);
font-family: var(--font-mono);
@@ -280,7 +312,17 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
padding: var(--space-1) 0;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.link-href:hover {
color: var(--accent-blue);
text-decoration: underline;
background: var(--bg-hover);
}
.link-href:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 1px;
}
.link-actions {
@@ -94,8 +94,10 @@
searchTimeout = setTimeout(async () => {
try {
const resp = await api.search(query, buildFilters(0));
results = resp.results;
total = resp.total;
// Defensive: some backends / error paths can send `null` for
// an absent array. Coalesce so downstream `.length` is safe.
results = resp.results ?? [];
total = resp.total ?? 0;
facets = resp.facets;
selectedIdx = 0;
} catch {
@@ -118,7 +120,7 @@
const resp = await api.search(query, buildFilters(results.length));
// Discard if query or filters changed while loading
if (query !== snapshotQuery || filterCollection !== snapshotCollection || filterStatus !== snapshotStatus) return;
results = [...results, ...resp.results];
results = [...results, ...(resp.results ?? [])];
} catch {
// ignore
} finally {
+169 -29
View File
@@ -70,61 +70,201 @@ export function unescapeDocLinks(markdown: string): string {
return markdown.replace(/\\\[\\\[([^\]]+)\\\]\\\]/g, '[[$1]]');
}
// Wiki-link reference pattern: uppercase/alphanumeric prefix, hyphen, digits.
// Matches the item ref format produced by formatItemRef (e.g. TASK-5, BUG-585).
// Anchored so it rejects anything else and falls back to title-based lookup,
// which keeps legacy [[Title]] links working unchanged.
const REF_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
/**
* Convert [[Item Title]] to markdown links for Tiptap rendering.
* Tiptap doesn't understand [[]] syntax, so we convert to standard
* markdown links before feeding content to the editor.
* Convert wiki-link storage syntax into markdown links for Tiptap rendering.
* Supports three forms, in preference order:
* - [[REF-123]] → ref lookup; visible text = current item title
* - [[REF-123|Display Text]] → ref lookup; visible text = Display Text
* - [[Title]] → legacy title lookup (also accepts [[coll/Title]])
* The ref-based forms are safe for titles containing any characters (brackets,
* slashes, quotes, etc.) because the stored key is the opaque item ref.
*/
export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlug: string, username?: string): string {
return content.replace(/\[\[([^\]]+)\]\]/g, (_match, title: string) => {
// Support optional collection/ prefix: [[tasks/My Task]]
let searchTitle = title;
let collFilter: string | null = null;
if (title.includes('/')) {
const [coll, ...rest] = title.split('/');
collFilter = coll;
searchTitle = rest.join('/');
// Body may contain backslash-escaped chars (`\]`, `\\`, `\|`) so the tokens
// we emit can carry arbitrary display text. The capture is (\\.|[^\]\\])+,
// i.e. "a backslash-escaped char OR any non-`]`/non-`\` char".
return content.replace(/\[\[((?:\\.|[^\]\\])+)\]\]/g, (_match, body: string) => {
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
// Split optional display override on the FIRST unescaped pipe. We do
// this up-front so REF_PATTERN can check the key alone (a ref like
// "BUG-585" contains no pipe, so this is a no-op for ref storage).
const { key: rawKey, displayOverride: rawDisplay } = splitWikiBody(body);
const key = unescapeWikiBody(rawKey);
const displayOverride = rawDisplay == null ? null : unescapeWikiBody(rawDisplay);
// 1. Ref-based lookup FIRST. Ref storage is our canonical form, so
// it must win over any legacy title that happens to match the
// ref literal — otherwise `[[BUG-585]]` could silently retarget
// onto a user-created item whose title is "BUG-585". If the ref
// doesn't resolve we FALL THROUGH to the legacy title path,
// because a ref-shaped body like `[[ISO-9001]]` may legitimately
// be a pre-existing title link.
if (REF_PATTERN.test(key.trim())) {
const ref = key.trim();
const byRef = items.find(i =>
!!i.item_number && !!i.collection_prefix &&
`${i.collection_prefix}-${i.item_number}`.toLowerCase() === ref.toLowerCase()
);
if (byRef && byRef.collection_slug) {
const text = displayOverride ?? byRef.title;
return `[${escapeMarkdownLinkText(text)}](${prefix}/${byRef.collection_slug}/${itemUrlId(byRef)})`;
}
// Intentional fall-through to the legacy title lookups below.
}
const item = items.find(i => {
const titleMatch = i.title.toLowerCase() === searchTitle.toLowerCase();
if (collFilter && i.collection_slug) {
return titleMatch && i.collection_slug === collFilter;
// 2. Legacy: exact full-body title match, BEFORE the pipe split.
// Handles pre-existing stored titles that contain a literal `|`
// (e.g. "[[A|B]]" where the item's real title is "A|B"). Only
// relevant when the body actually has a pipe — otherwise the
// already-split `key` is identical to the full body.
if (rawDisplay != null) {
const fullBody = unescapeWikiBody(body);
const fullTitleItem = items.find(i => i.title.toLowerCase() === fullBody.toLowerCase());
if (fullTitleItem && fullTitleItem.collection_slug) {
return `[${escapeMarkdownLinkText(fullTitleItem.title)}](${prefix}/${fullTitleItem.collection_slug}/${itemUrlId(fullTitleItem)})`;
}
return titleMatch;
});
// Collection-qualified legacy form whose title contains a pipe.
if (fullBody.includes('/')) {
const [qualColl, ...qualRest] = fullBody.split('/');
const qualTitle = qualRest.join('/');
const qualItem = items.find(i =>
i.title.toLowerCase() === qualTitle.toLowerCase() &&
i.collection_slug === qualColl
);
if (qualItem && qualItem.collection_slug) {
return `[${escapeMarkdownLinkText(qualItem.title)}](${prefix}/${qualItem.collection_slug}/${itemUrlId(qualItem)})`;
}
}
}
// 3. Legacy: exact title match on the key.
const titleLower = key.toLowerCase();
let item = items.find(i => i.title.toLowerCase() === titleLower);
let displayText = displayOverride ?? key;
// 4. Legacy: the [[collection/Title]] disambiguation syntax.
if (!item && key.includes('/')) {
const [collFilter, ...rest] = key.split('/');
const searchTitle = rest.join('/');
const found = items.find(i =>
i.title.toLowerCase() === searchTitle.toLowerCase() &&
i.collection_slug === collFilter
);
if (found) {
item = found;
if (displayOverride == null) displayText = searchTitle;
}
}
if (item && item.collection_slug) {
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
return `[${searchTitle}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`;
return `[${escapeMarkdownLinkText(displayText)}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`;
}
// Unresolved — render as styled text (editor will show it as plain text)
return `[${searchTitle}](broken)`;
// Unresolved: leave the original [[X]] text alone. Emitting a
// [text](broken) link here would hijack content that legitimately
// contains `[[` — for example a `[[` that appears inside another
// markdown link's text span. The regex is greedy and may grab a
// range that was never intended as a wiki-link, so the safe thing
// on miss is to restore the match verbatim.
return _match;
});
}
/**
* Convert markdown links back to [[Item Title]] syntax for storage.
* Reverses wikiLinksToMarkdown() so we store [[]] not []() in the database.
* Convert markdown links back to wiki-link storage syntax.
* When the link's URL resolves to an item with a ref, emit [[REF]] (or
* [[REF|Display]] if the visible text differs from the item's current
* title). Ref-based storage is preferred because it survives item renames
* and is robust against special characters in titles.
* Items without a ref fall back to the legacy [[Title]] form.
*/
export function markdownToWikiLinks(markdown: string, items: Item[]): string {
// Match [Title](/username/workspace/collection/slug-or-REF) or [Title](/workspace/collection/slug-or-REF) pattern
return markdown.replace(/\[([^\]]+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, title: string, slugOrRef: string) => {
// Match [Title](/username/workspace/collection/slug-or-REF). Title may
// contain backslash-escaped chars (\[, \], \\) that tiptap-markdown emits
// when serializing link text. The capture allows `\.` sequences so we
// don't terminate on an escaped `]` that's really part of the display.
return markdown.replace(/\[((?:\\.|[^\]\\])+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, rawText: string, slugOrRef: string) => {
const item = items.find(i => {
if (i.slug === slugOrRef) return true;
// Also match PREFIX-NUMBER refs
if (i.item_number && i.collection_prefix) {
return `${i.collection_prefix}-${i.item_number}` === slugOrRef;
}
return false;
});
if (item) {
return `[[${title}]]`;
if (!item) return _match;
// tiptap-markdown emits backslash-escaped brackets in the link text
// (e.g. "Use \[\[ to link"); unescape before comparing/emitting.
const displayText = unescapeMarkdownLinkText(rawText);
const ref = (item.item_number && item.collection_prefix)
? `${item.collection_prefix}-${item.item_number}`
: null;
if (ref) {
// Prefer ref-based storage. Omit |Display if it matches the
// current item title (renaming the item updates the link text
// automatically on next load).
if (displayText === item.title) {
return `[[${ref}]]`;
}
return `[[${ref}|${escapeWikiBody(displayText)}]]`;
}
return _match;
// Legacy fallback for items without a ref.
return `[[${escapeWikiBody(displayText)}]]`;
});
}
// Escape the characters that would terminate or unbalance a markdown link's
// text span. `\` must be doubled first so it doesn't interfere with the
// subsequent bracket escapes.
function escapeMarkdownLinkText(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/([\[\]])/g, '\\$1');
}
// Escape the characters that would terminate a [[...]] wiki-link body, or
// collide with the `|` display separator. Order matters: backslash first.
function escapeWikiBody(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/([\]|])/g, '\\$1');
}
// Inverse of escapeWikiBody. Accepts `\]`, `\|`, and `\\` escapes.
function unescapeWikiBody(s: string): string {
return s.replace(/\\(\\|\]|\|)/g, '$1');
}
// Split a wiki-link body on the FIRST unescaped `|`. Returns the raw key
// and the raw display override (both still escape-encoded — caller should
// unescape them). If there's no pipe, displayOverride is null.
function splitWikiBody(body: string): { key: string; displayOverride: string | null } {
let i = 0;
while (i < body.length) {
const ch = body[i];
if (ch === '\\' && i + 1 < body.length) {
i += 2;
continue;
}
if (ch === '|') {
return { key: body.slice(0, i), displayOverride: body.slice(i + 1) };
}
i++;
}
return { key: body, displayOverride: null };
}
// Inverse of escapeMarkdownLinkText. Also undoes the \[\[ / \]\] escapes that
// tiptap-markdown inserts to prevent its own output from looking like our
// wiki-link sentinels.
function unescapeMarkdownLinkText(s: string): string {
return s.replace(/\\(\[|\]|\\)/g, '$1');
}
/**
* Convert [[broken]] placeholder links back to wiki syntax
*/
+8 -1
View File
@@ -20,7 +20,14 @@
let authReady = $state(false);
let workspacesLoaded = $state(false);
let authLoadFailed = $state(false);
let isAuthPage = $derived(page.url.pathname === '/login' || page.url.pathname === '/register' || page.url.pathname.startsWith('/join/') || page.url.pathname.startsWith('/auth/cli/'));
let isAuthPage = $derived(
page.url.pathname === '/login'
|| page.url.pathname === '/register'
|| page.url.pathname === '/forgot-password'
|| page.url.pathname.startsWith('/reset-password/')
|| page.url.pathname.startsWith('/join/')
|| page.url.pathname.startsWith('/auth/cli/')
);
let isSharePage = $derived(page.url.pathname.startsWith('/s/'));
let isConsolePage = $derived(page.url.pathname.startsWith('/console'));