mirror of
https://github.com/vicky469/aside.git
synced 2026-07-22 17:42:04 +00:00
Removes the resolved/unresolve comment feature entirely. Comments are now always visible — there is no archived state or toggle. - Removes `resolved?: boolean` from CommentThread, Comment, and all storage/normalization paths - Removes resolveComment/unresolveComment from CommentManager, CommentMutationController, and public plugin API - Removes shouldShowResolvedComments/setShowResolvedComments from session, mutation, and persistence controllers - Removes sidebar resolve button, toolbar chip, and resolved empty states from AsideView - Removes setThreadResolved sync event generation; keeps the op in the union type as a backward-compatible no-op so old stored events parse without error - Deletes resolvedCommentVisibility.ts, commentSelectionVisibility.ts, and resolve-note-comment.mjs scripts - Updates README to remove resolved feature mentions - Updates all tests accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
import * as assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import test from "node:test";
|
|
import type { DataAdapter } from "obsidian";
|
|
import type { CommentThread } from "../src/commentManager";
|
|
import { SidecarCommentStorage } from "../src/core/storage/sidecarCommentStorage";
|
|
|
|
class FakeAdapter implements Pick<DataAdapter, "exists" | "mkdir" | "write" | "read" | "remove" | "rename" | "list"> {
|
|
public readonly directories = new Set<string>();
|
|
public readonly files = new Map<string, string>();
|
|
|
|
async exists(normalizedPath: string): Promise<boolean> {
|
|
return this.directories.has(normalizedPath) || this.files.has(normalizedPath);
|
|
}
|
|
|
|
async mkdir(normalizedPath: string): Promise<void> {
|
|
this.directories.add(normalizedPath);
|
|
}
|
|
|
|
async write(normalizedPath: string, data: string): Promise<void> {
|
|
this.files.set(normalizedPath, data);
|
|
}
|
|
|
|
async read(normalizedPath: string): Promise<string> {
|
|
const content = this.files.get(normalizedPath);
|
|
if (content === undefined) {
|
|
throw new Error(`Missing file: ${normalizedPath}`);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
async remove(normalizedPath: string): Promise<void> {
|
|
this.files.delete(normalizedPath);
|
|
}
|
|
|
|
async rename(normalizedPath: string, normalizedNewPath: string): Promise<void> {
|
|
const content = this.files.get(normalizedPath);
|
|
if (content === undefined) {
|
|
throw new Error(`Missing file: ${normalizedPath}`);
|
|
}
|
|
|
|
this.files.set(normalizedNewPath, content);
|
|
this.files.delete(normalizedPath);
|
|
}
|
|
|
|
async list(normalizedPath: string): Promise<{ files: string[]; folders: string[] }> {
|
|
const prefix = normalizedPath.endsWith("/") ? normalizedPath : `${normalizedPath}/`;
|
|
const files: string[] = [];
|
|
const folders = new Set<string>();
|
|
for (const filePath of this.files.keys()) {
|
|
if (!filePath.startsWith(prefix)) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = filePath.slice(prefix.length);
|
|
const slashIndex = relativePath.indexOf("/");
|
|
if (slashIndex === -1) {
|
|
files.push(filePath);
|
|
} else {
|
|
folders.add(`${prefix}${relativePath.slice(0, slashIndex)}`);
|
|
}
|
|
}
|
|
return {
|
|
files: files.sort((left, right) => left.localeCompare(right)),
|
|
folders: Array.from(folders).sort((left, right) => left.localeCompare(right)),
|
|
};
|
|
}
|
|
}
|
|
|
|
class EnoentOnRemoveAdapter extends FakeAdapter {
|
|
public readonly enoentOnRemove = new Set<string>();
|
|
|
|
async remove(normalizedPath: string): Promise<void> {
|
|
if (this.enoentOnRemove.has(normalizedPath)) {
|
|
this.files.delete(normalizedPath);
|
|
const error = new Error(`ENOENT: no such file or directory, unlink '${normalizedPath}'`) as Error & { code: string };
|
|
error.code = "ENOENT";
|
|
throw error;
|
|
}
|
|
|
|
await super.remove(normalizedPath);
|
|
}
|
|
}
|
|
|
|
function hashText(text: string): string {
|
|
return createHash("sha256").update(text).digest("hex");
|
|
}
|
|
|
|
function createThread(filePath: string): CommentThread {
|
|
return {
|
|
id: "thread-1",
|
|
filePath,
|
|
startLine: 1,
|
|
startChar: 2,
|
|
endLine: 1,
|
|
endChar: 7,
|
|
selectedText: "target",
|
|
selectedTextHash: "hash-target",
|
|
anchorKind: "selection",
|
|
orphaned: false,
|
|
entries: [{
|
|
id: "entry-1",
|
|
body: "hello",
|
|
timestamp: 1710000000000,
|
|
}],
|
|
createdAt: 1710000000000,
|
|
updatedAt: 1710000000000,
|
|
};
|
|
}
|
|
|
|
test("sidecar comment storage writes hashed per-note files and reads them back", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const notePath = "books/example.md";
|
|
const expectedHash = hashText(notePath);
|
|
const expectedPath = `.obsidian/plugins/aside/sidenotes/by-note/${expectedHash.slice(0, 2)}/${expectedHash}.json`;
|
|
|
|
await storage.write(notePath, [createThread(notePath)]);
|
|
|
|
assert.equal(await storage.exists(notePath), true);
|
|
assert.equal(adapter.directories.has(".obsidian/plugins/aside/sidenotes/by-note"), true);
|
|
assert.equal(adapter.files.has(expectedPath), true);
|
|
|
|
const readThreads = await storage.read(notePath);
|
|
assert.ok(readThreads);
|
|
assert.equal(readThreads.length, 1);
|
|
assert.equal(readThreads[0].filePath, notePath);
|
|
assert.equal(readThreads[0].entries[0]?.body, "hello");
|
|
});
|
|
|
|
test("sidecar comment storage renames the hashed file when the note path changes", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const originalNotePath = "books/original.md";
|
|
const renamedNotePath = "books/renamed.md";
|
|
|
|
await storage.write(originalNotePath, [createThread(originalNotePath)]);
|
|
const originalStoragePath = await storage.getNoteStoragePath(originalNotePath);
|
|
const renamedStoragePath = await storage.getNoteStoragePath(renamedNotePath);
|
|
|
|
await storage.rename(originalNotePath, renamedNotePath);
|
|
|
|
assert.equal(adapter.files.has(originalStoragePath), false);
|
|
assert.equal(adapter.files.has(renamedStoragePath), true);
|
|
|
|
const renamedThreads = await storage.read(renamedNotePath);
|
|
assert.ok(renamedThreads);
|
|
assert.equal(renamedThreads[0].filePath, renamedNotePath);
|
|
});
|
|
|
|
test("sidecar comment storage writes source-id keyed files and retargets threads on read", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const sourceId = "src-123";
|
|
const originalNotePath = "books/original.md";
|
|
const renamedNotePath = "books/renamed.md";
|
|
const expectedHash = hashText(sourceId);
|
|
const expectedPath = `.obsidian/plugins/aside/sidenotes/by-source/${expectedHash.slice(0, 2)}/${expectedHash}.json`;
|
|
|
|
await storage.writeForSource(sourceId, originalNotePath, [createThread(originalNotePath)]);
|
|
|
|
assert.equal(await storage.existsForSource(sourceId), true);
|
|
assert.equal(adapter.files.has(expectedPath), true);
|
|
|
|
const readThreads = await storage.readForSource(sourceId, renamedNotePath);
|
|
assert.ok(readThreads);
|
|
assert.equal(readThreads[0].filePath, renamedNotePath);
|
|
assert.equal(readThreads[0].entries[0]?.body, "hello");
|
|
});
|
|
|
|
test("sidecar comment storage reads legacy SideNote2 cache paths", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
legacyPluginDirPaths: [".obsidian/plugins/side-note2"],
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const notePath = "books/example.md";
|
|
const expectedHash = hashText(notePath);
|
|
const legacyPath = `.obsidian/plugins/side-note2/sidenotes/by-note/${expectedHash.slice(0, 2)}/${expectedHash}.json`;
|
|
adapter.files.set(legacyPath, `${JSON.stringify({
|
|
version: 1,
|
|
notePath,
|
|
threads: [createThread(notePath)],
|
|
})}\n`);
|
|
|
|
assert.equal(await storage.exists(notePath), true);
|
|
const readThreads = await storage.read(notePath);
|
|
assert.ok(readThreads);
|
|
assert.equal(readThreads[0].filePath, notePath);
|
|
assert.equal(readThreads[0].entries[0]?.body, "hello");
|
|
});
|
|
|
|
test("sidecar comment storage removes the sidecar file when the thread list becomes empty", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const notePath = "books/example.md";
|
|
|
|
await storage.write(notePath, [createThread(notePath)]);
|
|
await storage.write(notePath, []);
|
|
|
|
assert.equal(await storage.exists(notePath), false);
|
|
assert.equal(await storage.read(notePath), null);
|
|
});
|
|
|
|
test("sidecar comment storage removes note and source records for one missing note", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const removedNotePath = "books/deleted.md";
|
|
const keptNotePath = "books/kept.md";
|
|
|
|
await storage.write(removedNotePath, [createThread(removedNotePath)]);
|
|
await storage.writeForSource("src-deleted", removedNotePath, [createThread(removedNotePath)]);
|
|
await storage.write(keptNotePath, [createThread(keptNotePath)]);
|
|
|
|
const removed = await storage.removeNote(removedNotePath);
|
|
const remaining = await storage.listStoredComments();
|
|
|
|
assert.equal(removed?.notePath, removedNotePath);
|
|
assert.equal(removed?.sourceId, "src-deleted");
|
|
assert.equal(await storage.exists(removedNotePath), false);
|
|
assert.equal(await storage.existsForSource("src-deleted"), false);
|
|
assert.deepEqual(remaining.map((record) => record.notePath), [keptNotePath]);
|
|
});
|
|
|
|
test("sidecar comment storage lists records across note and source sidecars without duplicates", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const notePath = "books/example.md";
|
|
|
|
await storage.write(notePath, [createThread(notePath)]);
|
|
await storage.writeForSource("src-example", notePath, [createThread(notePath)]);
|
|
|
|
const records = await storage.listStoredComments();
|
|
|
|
assert.equal(records.length, 1);
|
|
assert.equal(records[0].notePath, notePath);
|
|
assert.equal(records[0].sourceId, "src-example");
|
|
});
|
|
|
|
test("sidecar comment storage removes note and source sidecars under a deleted folder", async () => {
|
|
const adapter = new FakeAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const deletedNotePath = "Deleted/note.md";
|
|
const nestedDeletedNotePath = "Deleted/nested/other.md";
|
|
const keptNotePath = "Deletedness/keep.md";
|
|
|
|
await storage.write(deletedNotePath, [createThread(deletedNotePath)]);
|
|
await storage.writeForSource("src-deleted", deletedNotePath, [createThread(deletedNotePath)]);
|
|
await storage.write(nestedDeletedNotePath, [createThread(nestedDeletedNotePath)]);
|
|
await storage.writeForSource("src-nested", nestedDeletedNotePath, [createThread(nestedDeletedNotePath)]);
|
|
await storage.write(keptNotePath, [createThread(keptNotePath)]);
|
|
await storage.writeForSource("src-kept", keptNotePath, [createThread(keptNotePath)]);
|
|
|
|
const removed = await storage.removeFolder("Deleted");
|
|
|
|
assert.deepEqual(
|
|
removed.map((record) => ({
|
|
notePath: record.notePath,
|
|
sourceId: record.sourceId,
|
|
})).sort((left, right) => left.notePath.localeCompare(right.notePath)),
|
|
[
|
|
{ notePath: nestedDeletedNotePath, sourceId: "src-nested" },
|
|
{ notePath: deletedNotePath, sourceId: "src-deleted" },
|
|
],
|
|
);
|
|
assert.equal(await storage.exists(deletedNotePath), false);
|
|
assert.equal(await storage.existsForSource("src-deleted"), false);
|
|
assert.equal(await storage.exists(nestedDeletedNotePath), false);
|
|
assert.equal(await storage.existsForSource("src-nested"), false);
|
|
assert.equal(await storage.exists(keptNotePath), true);
|
|
assert.equal(await storage.existsForSource("src-kept"), true);
|
|
});
|
|
|
|
test("sidecar comment storage ignores ENOENT while removing a deleted folder", async () => {
|
|
const adapter = new EnoentOnRemoveAdapter();
|
|
const storage = new SidecarCommentStorage({
|
|
adapter: adapter as unknown as DataAdapter,
|
|
pluginDirPath: ".obsidian/plugins/aside",
|
|
hashText: async (text) => hashText(text),
|
|
});
|
|
const deletedNotePath = "Deleted/note.md";
|
|
|
|
await storage.write(deletedNotePath, [createThread(deletedNotePath)]);
|
|
const storagePath = await storage.getNoteStoragePath(deletedNotePath);
|
|
adapter.enoentOnRemove.add(storagePath);
|
|
|
|
const removed = await storage.removeFolder("Deleted");
|
|
|
|
assert.deepEqual(removed.map((record) => record.notePath), [deletedNotePath]);
|
|
assert.equal(await storage.exists(deletedNotePath), false);
|
|
});
|