Remove [[wiki-links]], backlinks and the graph
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s

Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
This commit is contained in:
2026-08-22 12:00:57 -04:00
parent 982d24c83b
commit bc22f8e249
38 changed files with 216 additions and 1485 deletions
+1 -4
View File
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo";
import type { DeviceToken, ImportResult, Repo } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -51,7 +51,6 @@ export const local: Repo = {
list: (query) => invoke<Note[]>("notes_list", { query }),
get: (id) => invoke<Note>("notes_get", { id }),
create: (input) => invoke<Note>("notes_create", { input }),
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
@@ -73,8 +72,6 @@ export const local: Repo = {
reminders: () => invoke<Note[]>("notes_reminders"),
titles: () => invoke<TitleEntry[]>("notes_titles"),
search: (q) => invoke<Note[]>("notes_search", { q }),
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
},
savedFilters: {
-7
View File
@@ -49,10 +49,6 @@ export interface ChecklistItemChanges {
checked?: boolean;
}
export interface Backlink {
id: string;
title: string;
}
export interface ImportResult {
source: string;
@@ -97,7 +93,6 @@ export interface NotesRepo {
list(query: NoteListQuery): Promise<Note[]>;
get(id: string): Promise<Note>;
create(input: NoteCreateInput): Promise<Note>;
createTitled(title: string): Promise<Note>;
update(id: string, changes: NoteChanges): Promise<Note>;
completeReminder(id: string): Promise<Note>;
snoozeReminder(id: string, minutes: number): Promise<Note>;
@@ -119,8 +114,6 @@ export interface NotesRepo {
reminders(): Promise<Note[]>;
titles(): Promise<TitleEntry[]>;
search(q: string): Promise<Note[]>;
backlinks(id: string): Promise<Backlink[]>;
linkSearch(q: string): Promise<TitleEntry[]>;
}
export interface SavedFiltersRepo {
-5
View File
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type {
Backlink,
DeviceToken,
ImportResult,
NoteChanges,
@@ -80,7 +79,6 @@ export const rest: Repo = {
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
get: (id) => api.get<Note>(`/api/notes/${id}`),
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
@@ -103,9 +101,6 @@ export const rest: Repo = {
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
linkSearch: async (q) =>
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
},
savedFilters: {
+2 -13
View File
@@ -49,7 +49,6 @@ const shortcuts = [
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
{ label: "Search", keys: ["/"] },
{ label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Go to Timeline", keys: ["g", "t"] },
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
void router.push("/");
return;
}
if (e.key === "g") {
e.preventDefault();
void router.push("/graph");
return;
}
if (e.key === "r") {
e.preventDefault();
void router.push("/reminders");
@@ -207,7 +201,7 @@ watch(
*
* Keyed off the route name rather than each view declaring its own title, so the
* label sits in one place and can't go missing (the board and search never had one)
* or drift in styling (timeline, reminders and graph each had their own h1).
* or drift in styling (timeline and reminders each had their own h1).
*
* A label lens is named by the label itself — "Groceries" is what the user came
* looking for; "Label" would tell them nothing they didn't already know.
@@ -224,8 +218,6 @@ const lensName = computed<string>(() => {
return "Timeline";
case "reminders":
return "Reminders";
case "graph":
return "Graph";
case "label":
// The store may not have loaded yet on a deep link; fall back rather than
// flashing an empty slot.
@@ -403,9 +395,6 @@ async function signOut() {
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
<Icon name="note" /> Notes
</RouterLink>
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
<Icon name="graph" /> Graph
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
@@ -522,7 +511,7 @@ async function signOut() {
BoardView, so keying on the route would remount it blanking the board
and refetching, which is precisely the page-change feeling this is meant
to remove. Unkeyed, Vue only transitions when the component TYPE changes
(board search timeline graph), and moving between the board's own
(board search timeline), and moving between the board's own
lenses stays an in-place reflow that NoteGrid animates. -->
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
<RouterView v-slot="{ Component }">
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
const list: Row[] = [
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
-1
View File
@@ -16,7 +16,6 @@ const paths: Record<string, string> = {
check: '<path d="M20 6 9 17l-5-5"/>',
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
+5 -53
View File
@@ -1,65 +1,17 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useNotesStore, type NoteLinkRef } from "../stores/notes";
import { useTitlesStore } from "../stores/titles";
import type { InlineToken } from "../notes/markdown";
const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
const router = useRouter();
const notes = useNotesStore();
const titles = useTitlesStore();
/**
* Where a [[link]] token actually points, according to the server.
*
* Keyed on the normalized written text, which is what survives in the body — the
* server resolved it to an id when the link was saved, so this keeps working after
* the target has been renamed and the written text has gone stale.
*/
const bound = computed(() => {
const map = new Map<string, NoteLinkRef>();
for (const l of props.links ?? []) map.set(l.norm, l);
return map;
});
/** What to SHOW for a link: the target's current name, else the text as written. */
function label(token: string): string {
return bound.value.get(token.trim().toLowerCase())?.title ?? token;
}
// Open the target via the board's ?open=<id> mechanism, creating the note first if
// the link names one that doesn't exist — which is a supported way to make a note.
async function follow(token: string) {
const hit = bound.value.get(token.trim().toLowerCase());
if (hit) {
void router.push({ path: "/", query: { open: hit.id } });
return;
}
// No server binding: either this is running offline against the local store, or
// the link genuinely resolves to nothing. The name index answers the first case.
await titles.load();
const byName = titles.resolve(token);
const id = byName ? byName.id : (await notes.createTitled(token)).id;
if (!byName) await titles.reload();
void router.push({ path: "/", query: { open: id } });
}
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
defineProps<{ tokens: InlineToken[] }>();
</script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
spaces are preserved and no extra spaces are introduced. -->
<template
><template v-for="(t, i) in tokens" :key="i"
><span
v-if="t.type === 'link'"
role="link"
tabindex="0"
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
@click.stop="follow(t.value)"
@keydown.enter.stop.prevent="follow(t.value)"
>{{ label(t.value) }}</span
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
><code
v-else-if="t.type === 'code'"
+8 -11
View File
@@ -2,38 +2,35 @@
import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue";
import type { NoteLinkRef } from "../stores/notes";
// `links` is the owning note's resolved [[links]], passed straight through to every
// inline run — only MarkdownInline uses it, but only this component knows the note.
const props = defineProps<{ text: string; links?: NoteLinkRef[] }>();
const props = defineProps<{ text: string }>();
const blocks = computed(() => parseMarkdown(props.text));
</script>
<template>
<div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i">
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
<blockquote
v-else-if="b.type === 'quote'"
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
>
<MarkdownInline :tokens="b.inline ?? []" :links="links" />
<MarkdownInline :tokens="b.inline ?? []" />
</blockquote>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
</ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
</ol>
<pre
v-else-if="b.type === 'pre'"
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
>{{ b.value ?? "" }}</pre
>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></p>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
</template>
</div>
</template>
+1 -1
View File
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
{{ note.title }}
</h3>
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="note.body" :links="note.links" />
<MarkdownText :text="note.body" />
</div>
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
Empty note
+9 -199
View File
@@ -3,7 +3,6 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
import { repo } from "../adapters";
import { useNotesStore } from "../stores/notes";
import { useConfigStore } from "../stores/config";
import { useTitlesStore, type TitleEntry } from "../stores/titles";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
@@ -27,7 +26,6 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
const notes = useNotesStore();
const config = useConfigStore();
const titles = useTitlesStore();
const noteId = ref<string | null>(props.note?.id ?? null);
const title = ref(props.note?.title ?? "");
@@ -40,7 +38,6 @@ const root = ref<HTMLElement | null>(null);
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
const backlinks = ref<{ id: string; title: string }[]>([]);
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
@@ -163,7 +160,6 @@ function resetCompose(): void {
labelList.value = [];
createKind.value = "text";
baseline.value = { title: null, body: "", color: "default" };
linkMenu.value = false;
uploadError.value = "";
}
@@ -242,22 +238,7 @@ function onBackdropMousedown(): void {
void dismiss();
}
async function loadBacklinks(): Promise<void> {
if (!noteId.value) {
backlinks.value = [];
return;
}
try {
backlinks.value = await repo.notes.backlinks(noteId.value);
} catch {
backlinks.value = [];
}
}
watch(() => noteId.value, loadBacklinks);
onMounted(async () => {
void titles.load();
void loadBacklinks();
await nextTick();
const el = bodyInput.value;
el?.focus();
@@ -265,99 +246,6 @@ onMounted(async () => {
if (el) el.selectionStart = el.selectionEnd = el.value.length;
});
// ---- outgoing links (edit mode) ----
//
// Two sources, in order. The note's SAVED links carry the server's binding, so a
// target that has since been renamed still resolves and is listed under its current
// name. A link just typed into the textarea has no saved row yet, and the name index
// is the best that can be said about it until the note is saved.
const outgoingLinks = computed(() => {
const boundByNorm = new Map((props.note?.links ?? []).map((l) => [l.norm, l]));
const re = /\[\[([^[\]]+)\]\]/g;
const seen = new Set<string>();
const out: { title: string; id: string | null }[] = [];
let match: RegExpExecArray | null;
while ((match = re.exec(body.value)) !== null) {
const t = match[1].trim();
const key = t.toLowerCase();
if (t && !seen.has(key)) {
seen.add(key);
const hit = boundByNorm.get(key);
out.push(hit ? { title: hit.title, id: hit.id } : { title: t, id: titles.resolve(t)?.id ?? null });
}
}
return out;
});
async function openLink(link: { title: string; id: string | null }) {
if (link.id) {
emit("navigate", link.id);
return;
}
const created = await notes.createTitled(link.title);
await titles.reload();
emit("navigate", created.id);
}
// ---- [[ link autocomplete in the body textarea ----
const linkMenu = ref(false);
const linkQuery = ref("");
const linkStart = ref(-1);
const linkSelected = ref(0);
const linkMatches = ref<TitleEntry[]>([]);
let linkTimer: ReturnType<typeof setTimeout> | undefined;
function refreshLinkMatches() {
if (linkTimer) clearTimeout(linkTimer);
const q = linkQuery.value.trim();
linkTimer = setTimeout(async () => {
try {
const results = await repo.notes.linkSearch(q);
linkMatches.value = results.filter((r) => r.id !== noteId.value).slice(0, 8);
} catch {
linkMatches.value = [];
}
linkSelected.value = 0;
}, 120);
}
function onBodyInput() {
const el = bodyInput.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
const text = body.value.slice(0, caret);
const open = text.lastIndexOf("[[");
if (open === -1) {
linkMenu.value = false;
return;
}
const between = text.slice(open + 2);
if (between.includes("]") || between.includes("\n")) {
linkMenu.value = false;
return;
}
linkQuery.value = between;
linkStart.value = open;
linkSelected.value = 0;
linkMenu.value = true;
refreshLinkMatches();
}
function insertLink(t: string) {
const el = bodyInput.value;
const caret = el?.selectionStart ?? body.value.length;
const before = body.value.slice(0, linkStart.value);
const after = body.value.slice(caret);
const insertion = `[[${t}]]`;
body.value = before + insertion + after;
linkMenu.value = false;
const pos = before.length + insertion.length;
void nextTick(() => {
el?.focus();
el?.setSelectionRange(pos, pos);
});
}
function onBodyKeydown(e: KeyboardEvent) {
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -365,25 +253,6 @@ function onBodyKeydown(e: KeyboardEvent) {
void commitAndContinue();
return;
}
if (!linkMenu.value || linkMatches.value.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
linkSelected.value = Math.max(linkSelected.value - 1, 0);
} else if (e.key === "Enter" || e.key === "Tab") {
const m = linkMatches.value[linkSelected.value];
if (m) {
e.preventDefault();
insertLink(m.title);
}
} else if (e.key === "Escape") {
// Close only the menu — don't let Esc bubble to the frame's close/commit.
e.preventDefault();
e.stopPropagation();
linkMenu.value = false;
}
}
function onTitleEnter(e: KeyboardEvent) {
@@ -694,37 +563,15 @@ function revPreview(rev: NoteRevision): string {
@keydown.enter="onTitleEnter"
/>
<div v-if="!showChecklist" class="relative">
<textarea
ref="bodyInput"
v-model="body"
rows="8"
:placeholder="bodyPlaceholder"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onBodyInput"
@keydown="onBodyKeydown"
/>
<ul
v-if="linkMenu && linkMatches.length"
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<li v-for="(m, i) in linkMatches" :key="m.id">
<button
type="button"
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
:class="
i === linkSelected
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
"
@mousemove="linkSelected = i"
@mousedown.prevent="insertLink(m.title)"
>
<span class="truncate">{{ m.title }}</span>
</button>
</li>
</ul>
</div>
<textarea
v-if="!showChecklist"
ref="bodyInput"
v-model="body"
rows="8"
:placeholder="bodyPlaceholder"
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@keydown="onBodyKeydown"
/>
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
@@ -793,43 +640,6 @@ function revPreview(rev: NoteRevision): string {
</button>
</div>
<div
v-if="!isCreate && (outgoingLinks.length || backlinks.length)"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
>
<div v-if="outgoingLinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="link in outgoingLinks"
:key="link.title"
type="button"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
link.id
? 'bg-brand/15 text-brand-700 dark:text-brand'
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
"
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
@click="openLink(link)"
>
{{ link.title }}<span v-if="!link.id" class="opacity-60"></span>
</button>
</div>
</div>
<div v-if="backlinks.length">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
<div class="flex flex-wrap gap-1.5">
<button
v-for="b in backlinks"
:key="b.id"
type="button"
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
@click="emit('navigate', b.id)"
>
{{ b.title }}
</button>
</div>
</div>
</div>
+2 -2
View File
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
//
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
// - `list` is the host's local note array, tried first when resolving a navigated
// [[wiki-link]] target before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search, graph) still resolve
// note id before falling back to the store, then a fetch — so views
// that keep their own list (reminders, timeline, search) still resolve
// locally without duplicating the lookup.
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
const notes = useNotesStore();
+13 -10
View File
@@ -2,12 +2,12 @@
// stays plain text; this only formats what's shown. We render the parsed tree as
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
// left as plain text and never mistaken for a heading.
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
// a heading.
export interface InlineToken {
type: "text" | "bold" | "italic" | "code" | "link";
type: "text" | "bold" | "italic" | "code";
value: string;
}
@@ -19,9 +19,13 @@ export interface Block {
value?: string;
}
// Order matters: links + code are matched before emphasis so their contents aren't
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
// bold (**) before italic (*). Emphasis does not nest (v1).
//
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
const raw = m[0];
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length;
}
-1
View File
@@ -21,7 +21,6 @@ const router = createRouter({
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
+1 -29
View File
@@ -63,27 +63,11 @@ export interface NoteRevision {
created_at: string | null;
}
// One resolved [[wiki-link]] out of a note: the normalized text as WRITTEN, and the
// note it actually points at with that note's name as it stands NOW.
//
// The server sends this because the client can no longer work it out. Resolution used
// to be a name lookup in the titles index, which only held together because renaming
// a note rewrote the link text inside every note that linked to it. Links are bound
// by id now and bodies are left alone, so the written text can name something the
// target is no longer called — and only the server holds the binding.
export interface NoteLinkRef {
/** The link text as written, trimmed and lowercased — the key a token matches on. */
norm: string;
id: string;
/** The target's CURRENT name, which is what gets rendered. */
title: string;
}
export interface Note {
id: string;
title: string | null;
// The note's display NAME: explicit title, else its first body line (server-derived).
// Every note has one, so body-only notes are still nameable + [[link]]-able.
// Every note has one, so a body-only note still has something to be called.
display_title: string;
body: string;
color: NoteColor;
@@ -101,11 +85,6 @@ export interface Note {
items: ChecklistItem[];
attachments: Attachment[];
previews: LinkPreview[];
// Absent offline: the desktop's local store derives links at query time and has no
// resolution to send. Rendering falls back to the titles index there, which is
// exactly right for a store where nothing else can have renamed the target behind
// this client's back.
links?: NoteLinkRef[];
created_at: string | null;
updated_at: string | null;
}
@@ -245,12 +224,6 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function createTitled(title: string): Promise<Note> {
const created = await repo.notes.createTitled(title);
reconcile(created);
return created;
}
async function reorder(orderedIds: string[]): Promise<void> {
// Optimistically assign positions matching the backend (total - index), sort,
// then persist.
@@ -327,7 +300,6 @@ export const useNotesStore = defineStore("notes", () => {
deletePreview,
importNotes,
fetchOne,
createTitled,
reorder,
trash,
restore,
+7 -7
View File
@@ -7,7 +7,12 @@ export interface TitleEntry {
title: string;
}
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side.
// Owner's {id, name} index of every non-trashed note.
//
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for,
// because the command palette lists it so someone can jump straight to a note by
// name. That is recall, which is what this app is for; `resolve()` went with the
// links.
export const useTitlesStore = defineStore("titles", () => {
const items = ref<TitleEntry[]>([]);
const loaded = ref(false);
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
await load();
}
function resolve(title: string): TitleEntry | null {
const norm = title.trim().toLowerCase();
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
}
return { items, loaded, load, reload, resolve };
return { items, loaded, load, reload };
});
-420
View File
@@ -1,420 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import NoteEditor from "../components/NoteEditor.vue";
type NodeKind = "note" | "label";
interface GNode {
id: string;
title: string;
color: string;
kind: NodeKind;
labelId?: string;
x: number;
y: number;
vx: number;
vy: number;
}
interface GEdge {
source: string;
target: string;
kind?: string;
}
const WIDTH = 1000;
const HEIGHT = 700;
const router = useRouter();
const allNodes = ref<GNode[]>([]);
const edges = ref<GEdge[]>([]);
const loading = ref(true);
const error = ref("");
// Editor host: resolve a node id against the store, else fetch (shared controller).
const { editing, close: closeEditor, navigate } = useNoteEditor();
// Unlinked notes float in the space by default — the graph is a gentle overview,
// not a links-only surface. Label hubs are on by default so tags cluster notes.
const showAll = ref(true);
const showLabels = ref(true);
const svgRef = ref<SVGSVGElement | null>(null);
const gRef = ref<SVGGElement | null>(null);
// Pan/zoom applied to the inner <g>.
const scale = ref(1);
const tx = ref(0);
const ty = ref(0);
let frame = 0;
let raf = 0;
// Label membership edges drop out when the label hubs are hidden.
const visibleEdges = computed(() =>
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
);
const connectedIds = computed(() => {
const s = new Set<string>();
for (const e of visibleEdges.value) {
s.add(e.source);
s.add(e.target);
}
return s;
});
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
const activeNodes = computed(() =>
allNodes.value.filter((n) => {
if (n.kind === "label" && !showLabels.value) return false;
if (showAll.value) return true;
return connectedIds.value.has(n.id);
}),
);
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
const edgeLines = computed(() => {
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
for (const e of visibleEdges.value) {
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
const s = byId.get(e.source);
const t = byId.get(e.target);
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
}
return out;
});
function fill(color: string): string {
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
}
async function loadGraph() {
loading.value = true;
error.value = "";
try {
const res = await api.get<{
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
edges: GEdge[];
}>("/api/graph");
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const count = Math.max(res.nodes.length, 1);
allNodes.value = res.nodes.map((n, i) => {
const angle = (i / count) * Math.PI * 2;
return {
id: n.id,
title: n.title,
color: n.color,
kind: n.kind,
labelId: n.label_id,
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
vx: 0,
vy: 0,
};
});
edges.value = res.edges;
} catch (e) {
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
allNodes.value = [];
edges.value = [];
} finally {
loading.value = false;
}
reheat();
}
function reheat() {
frame = 0;
cancelAnimationFrame(raf);
raf = 0;
if (activeNodes.value.length) simulate();
}
function simulate() {
const list = activeNodes.value;
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const byId = new Map(list.map((n) => [n.id, n]));
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const a = list[i];
const b = list[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let d2 = dx * dx + dy * dy;
if (d2 < 0.01) {
d2 = 0.01;
dx = Math.random();
dy = Math.random();
}
const d = Math.sqrt(d2);
const force = 6000 / d2;
const fx = (dx / d) * force;
const fy = (dy / d) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
}
for (const e of visibleEdges.value) {
const s = byId.get(e.source);
const t = byId.get(e.target);
if (!s || !t) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
// Label-membership springs sit a touch longer so hubs ring their notes.
const rest = e.kind === "label" ? 150 : 130;
const diff = (d - rest) * 0.02;
const fx = (dx / d) * diff;
const fy = (dy / d) * diff;
s.vx += fx;
s.vy += fy;
t.vx -= fx;
t.vy -= fy;
}
for (const n of list) {
if (n === dragNode) {
n.vx = 0;
n.vy = 0;
continue; // pinned to the cursor while dragging
}
n.vx += (cx - n.x) * 0.002;
n.vy += (cy - n.y) * 0.002;
n.vx *= 0.85;
n.vy *= 0.85;
n.x += n.vx;
n.y += n.vy;
}
frame++;
// Keep running while cooling, or indefinitely while a node is being dragged.
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
}
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
let dragNode: GNode | null = null;
let dragMoved = false;
let downPos = { x: 0, y: 0 };
let panning = false;
let panLast = { x: 0, y: 0 };
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
const ctm = el?.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function onNodeDown(n: GNode, e: MouseEvent) {
e.stopPropagation();
dragNode = n;
dragMoved = false;
downPos = { x: e.clientX, y: e.clientY };
reheat();
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onBgDown(e: MouseEvent) {
panning = true;
panLast = toLocal(svgRef.value, e);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function onMove(e: MouseEvent) {
if (dragNode) {
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
const p = toLocal(gRef.value, e);
dragNode.x = p.x;
dragNode.y = p.y;
} else if (panning) {
const p = toLocal(svgRef.value, e);
tx.value += p.x - panLast.x;
ty.value += p.y - panLast.y;
panLast = p;
}
}
function onUp() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
const node = dragNode;
const moved = dragMoved;
dragNode = null;
panning = false;
// A press without a drag is a click.
if (node && !moved) clickNode(node);
}
function clickNode(n: GNode) {
// Label hub → jump to that label's board lens (one space, many lenses).
if (n.kind === "label" && n.labelId) {
void router.push(`/label/${n.labelId}`);
return;
}
void navigate(n.id);
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const vb = toLocal(svgRef.value, e);
const gx = (vb.x - tx.value) / scale.value;
const gy = (vb.y - ty.value) / scale.value;
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
tx.value = vb.x - gx * scale.value;
ty.value = vb.y - gy * scale.value;
}
function resetView() {
scale.value = 1;
tx.value = 0;
ty.value = 0;
}
function toggleAll() {
showAll.value = !showAll.value;
reheat();
}
function toggleLabels() {
showLabels.value = !showLabels.value;
reheat();
}
onMounted(loadGraph);
onBeforeUnmount(() => {
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<template>
<div class="flex h-full flex-col p-4">
<!-- Titled by the shell's persistent lens name (task 1913). -->
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
<div class="flex items-center gap-3 text-sm">
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
Show labels
</label>
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
Show unlinked notes
</label>
<button
type="button"
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="resetView"
>
Reset view
</button>
</div>
</div>
<AsyncState
:loading="loading"
:error="error || undefined"
error-title="Couldn't load the graph"
@retry="loadGraph"
>
<div v-if="allNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Create notes and link them with
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
</p>
</div>
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
<p class="mt-1 text-sm text-neutral-400">
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
show all notes
</button>.
</p>
</div>
<div
v-else
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
>
<svg
ref="svgRef"
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
class="h-full w-full cursor-grab select-none touch-none"
preserveAspectRatio="xMidYMid meet"
@mousedown="onBgDown"
@wheel="onWheel"
>
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
<line
v-for="(l, i) in edgeLines"
:key="`e${i}`"
:x1="l.x1"
:y1="l.y1"
:x2="l.x2"
:y2="l.y2"
class="stroke-neutral-300 dark:stroke-neutral-700"
:stroke-width="l.label ? 1 : 1.5"
:stroke-dasharray="l.label ? '3 3' : undefined"
/>
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
<circle
v-if="n.kind === 'label'"
:cx="n.x"
:cy="n.y"
r="12"
:fill="fill(n.color)"
fill-opacity="0.9"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="3"
/>
<circle
v-else
:cx="n.x"
:cy="n.y"
r="8"
:fill="fill(n.color)"
class="stroke-neutral-50 dark:stroke-neutral-950"
stroke-width="1.5"
/>
<text
:x="n.x"
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
text-anchor="middle"
:class="
n.kind === 'label'
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
"
>
{{ n.title }}
</text>
</g>
</g>
</svg>
</div>
</AsyncState>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="navigate" />
</template>
</div>
</template>