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
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user