mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
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:
@@ -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"`
|
||||
|
||||
@@ -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 = '[]';
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user