* [Major] Introduce a new `Ref` and `Path` type
This introduces a new `Ref` and `Path` type, which try to be as lightweight and rigorous as possible. More description in the PR
* [Minor] Updated Syntax
* [Minor] Updated Type
* [Minor] Renamed file
* [Minor] Updated type
* [Minor] Updated Syntax
* [Major] Rename css classes for wiki links
Previously all the parts of a wiki link were tagged with `sb-wiki-link-page`. This didn't make a whole lot of sense. Also the tag `sb-wiki-link` was could never render, because of the way `HighlightStyle` works. Now every part of a wiki link will be tagged with `sb-wiki-link` (i.e. the link, the alias, the dimensions). Invalid and missing links will be styled separately in a later commit.
* [Minor] Adjust to new API
* [Minor] Small changes
* [Minor] Function and file renames
* [Major] Syscall adjusments
Change behavior of `getCurrentPath` to actually return what's now considered a `Path`. `getCurrentPage(Meta)` now also correctly returns for documents. We could consider a rename to get rid of the `page` association
* [Major] Rework `Client`
Mostly minor adjustments for the new API. Some major changes in `navigateWithinPage` and some unecessary checks removed now
* [Major] Errors on navigate
This now throws an error when the user tries to navigate to a link that is invalid
* [Major] Minor adjustments and remove some old bulk
This does the typicall adjustments to the new API. A thing to consider is that invalid links aren't indexed. Also removed old code about template and query stuff. And reused a regex instead of copy pasting it.
* [Minor] Cleanup editor ui
* [Minor] Adjust to new API
* [Minor] Adjust to new API and reduce ugly regex
* [Minor] New API
* [Minor] Rework link cm plugin
* [Major] Rework wiki links
A lot of code cleanup, reworked for new APIs and also introduces "invalid links"
* [Minor] Part of the editor ui rework
* [Major] Adjust to new API
This also removes the `looksLikePathWithExtension` check. I don't think it was necessary.
* [Minor] PArt of editor ui rewrite
* [Minor] Adjust to new API
* [Major] Adjust renderer to new API
Invalid links now link to `#`
* [Major] Rewrite navigator
This still needs major rework, currently in a "got it working" state
* [Minor] Adjust file name
* [Minor] Mistake during rebase
* [Minor] Navigate to end of header
* [Minor] Remove `tweakEditorDOM` and use proper CM extensions
* [Major] Heavily clean up the navigation code
* [Minor] Make `PageNavigator` closable again
* [Minor] Small fixes to reduce errors bubbling up
* [Minor] Show error message if navigate fails
* [Major] Remove legacy `navigate({ kind = ` syntax
This removes the old navigation syntax. It's still supported but the user will be warned then using it. I also added some validation to editor.navigate for user provided refs.
* [Minor] Make notifications errors
* [Minor] Don't fail if header isn't found
* [Minor] Typo
* [Major] Remove cleanPageRef
* [Minor] Small bug
* [Minor] Align behavior between render and editor
* [Major] Remove `PageRef`. Pretty sure it's obsolete
* [Major] Remove pos from header as it's an invalid ref
* [Minor] Align with original behavior
* [Minor] Get tests working again
* [Minor] check that indexPage is valid
* [Major] Rewrite `resolve.ts` file
* [Minor] More tests, more fixes
* [Minor] Don't just append .md this is weird
* [Minor] More edge cases
* [Minor] Version bumping hono somehow fixes the `/` case
* [Minor] Smaller TODOs
* [Major] We can be a littler looser on the restrictions
* [Major] Change header indexing, to index with pos
* [Minor] More fixes for headers
* [Minor] Rename for clarity
* [Minor] Change more regexes to named groups
* [Major] Inline content rewrite
* [Minor] Fix TODO
* [Minor] Bump index version
* [Major] New Space Primitive to check paths
* [Minor] Fix #1214
* [Minor] Fix #1169
* [Minor] Remove TODO
This check is probably not needed anymore. Unsure. May look into it later
* [Minor] small bug
* [Minor] Not a good rebase without me needing to fix it afterwards
* [Minor] Improve fallback for file upload
* [Minor] Some fixes on the for resolving
* [Minor] Removed doubled test case
* [Minor] Resolve #860
* [Minor] Turn flash notification into an error
* [Minor] Change handling of `.` paths
* [Minor] Show empty URL for index page
* [Minor] Push correct ref to history
* [Major] Capture mini editor events in capture phase
This fixes the issue were when pressing arrowup or arrowdown changed the cursor position inside the editor
* [Major] Add prefix to `FilterList`
This now fixes issues were if you had a prefixed page you con't properly search for it, because the search considered the prefix as part of the name
* [Minor] A little rigor for empty headers
* [Major] Fix #1404
* [Minor] `parseInt` returns `NaN` handle that
* [Minor] Use `fileName`
* [Minor] Fix comment
* [Minor] Fixes
* [Major] disallow `foo.png.md` and `/../`, etc. cases
* [Major] Change name of conflicted pages
* [Minor] Remove unecessary check
* [Minor] Remove some split code
* [Minor] Allow deletion of invalid files
* [Minor] Bug in the regex
* [Minor] Give some possibility of fixing incorrect names
* [Minor] Tiny bug with header completion
* [Major] Docs
* [Minor] Ignore errors for navigation inside of a page
This could be a problem when SB e.g. cached a scrollTop for a page, which, because e.g. the file is now deleted, doesn't exist anymore and thus CM throws an error, when trying to navigate
* Update conflicted copy regex in Maintenance.md
---------
Co-authored-by: Zef Hemel <zef@zef.me>
517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
import type { IndexTreeEvent } from "../../type/event.ts";
|
|
|
|
import {
|
|
editor,
|
|
events,
|
|
markdown,
|
|
space,
|
|
sync,
|
|
} from "@silverbulletmd/silverbullet/syscalls";
|
|
|
|
import {
|
|
addParentPointers,
|
|
collectNodesMatching,
|
|
findNodeMatching,
|
|
findNodeOfType,
|
|
findParentMatching,
|
|
nodeAtPos,
|
|
type ParseTree,
|
|
renderToText,
|
|
replaceNodesMatching,
|
|
traverseTreeAsync,
|
|
} from "../../plug-api/lib/tree.ts";
|
|
import { niceDate } from "../../lib/dates.ts";
|
|
import {
|
|
cleanAttributes,
|
|
extractAttributes,
|
|
} from "@silverbulletmd/silverbullet/lib/attribute";
|
|
import { indexObjects } from "../index/plug_api.ts";
|
|
import {
|
|
cleanHashTags,
|
|
extractHashTags,
|
|
updateITags,
|
|
} from "@silverbulletmd/silverbullet/lib/tags";
|
|
import { extractFrontMatter } from "@silverbulletmd/silverbullet/lib/frontmatter";
|
|
import {
|
|
getNameFromPath,
|
|
getOffsetFromLineColumn,
|
|
isMarkdownPath,
|
|
parseToRef,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { enrichItemFromParents } from "../index/item.ts";
|
|
import { deepClone } from "@silverbulletmd/silverbullet/lib/json";
|
|
import { queryLuaObjects } from "../index/api.ts";
|
|
import type { ObjectValue } from "../../type/index.ts";
|
|
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
|
|
|
|
export type TaskObject = ObjectValue<
|
|
{
|
|
page: string;
|
|
pos: number;
|
|
name: string;
|
|
text: string;
|
|
done: boolean;
|
|
state: string;
|
|
deadline?: string;
|
|
} & Record<string, any>
|
|
>;
|
|
|
|
export type TaskStateObject = ObjectValue<{
|
|
state: string;
|
|
count: number;
|
|
page: string;
|
|
}>;
|
|
|
|
function getDeadline(deadlineNode: ParseTree): string {
|
|
return deadlineNode.children![0].text!.replace(/📅\s*/, "");
|
|
}
|
|
|
|
const completeStates = ["x", "X"];
|
|
const incompleteStates = [" "];
|
|
|
|
export async function extractTasks(
|
|
name: string,
|
|
tree: ParseTree,
|
|
): Promise<TaskObject[]> {
|
|
const tasks: ObjectValue<TaskObject>[] = [];
|
|
const taskStates = new Map<string, { count: number; firstPos: number }>();
|
|
const frontmatter = await extractFrontMatter(tree);
|
|
|
|
await traverseTreeAsync(tree, async (n) => {
|
|
if (n.type !== "Task") {
|
|
return false;
|
|
}
|
|
const listItemNode = n.parent!;
|
|
const state = n.children![0].children![1].text!;
|
|
if (!incompleteStates.includes(state) && !completeStates.includes(state)) {
|
|
let currentState = taskStates.get(state);
|
|
if (!currentState) {
|
|
currentState = { count: 0, firstPos: n.from! };
|
|
taskStates.set(state, currentState);
|
|
}
|
|
currentState.count++;
|
|
}
|
|
const complete = completeStates.includes(state);
|
|
|
|
const task: TaskObject = {
|
|
ref: `${name}@${n.from}`,
|
|
tag: "task",
|
|
name: "",
|
|
text: "",
|
|
done: complete,
|
|
page: name,
|
|
pos: n.from!,
|
|
state,
|
|
};
|
|
|
|
// The task text is everything after the task marker
|
|
task.text = n.children!.slice(1).map(renderToText).join("").trim();
|
|
|
|
// This finds the deadline and tags, and removes them from the tree
|
|
replaceNodesMatching(n, (tree) => {
|
|
if (tree.type === "DeadlineDate") {
|
|
task.deadline = getDeadline(tree);
|
|
// Remove this node from the tree
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Extract tags and attributes
|
|
task.tags = extractHashTags(n);
|
|
const extractedAttributes = await extractAttributes(n);
|
|
|
|
// Then clean them out
|
|
const clonedNode = deepClone(n, ["parent"]);
|
|
cleanHashTags(clonedNode);
|
|
cleanAttributes(clonedNode);
|
|
task.name = clonedNode.children!.slice(1).map(renderToText).join("").trim();
|
|
|
|
for (const [key, value] of Object.entries(extractedAttributes)) {
|
|
task[key] = value;
|
|
}
|
|
|
|
updateITags(task, frontmatter);
|
|
await enrichItemFromParents(listItemNode, task, name, frontmatter);
|
|
|
|
tasks.push(task);
|
|
return true;
|
|
});
|
|
|
|
// Index task states
|
|
if (taskStates.size > 0) {
|
|
await indexObjects<TaskStateObject>(
|
|
name,
|
|
Array.from(taskStates.entries()).map(([state, { firstPos, count }]) => ({
|
|
ref: `${name}@${firstPos}`,
|
|
tag: "taskstate",
|
|
state,
|
|
count,
|
|
page: name,
|
|
})),
|
|
);
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
export async function indexTasks({ name, tree }: IndexTreeEvent) {
|
|
const extractedTasks = await extractTasks(name, tree);
|
|
|
|
// Index tasks themselves
|
|
if (extractTasks.length > 0) {
|
|
await indexObjects(name, extractedTasks);
|
|
}
|
|
}
|
|
|
|
export function taskToggle(event: ClickEvent) {
|
|
if (event.altKey) {
|
|
return;
|
|
}
|
|
return taskCycleAtPos(event.pos);
|
|
}
|
|
|
|
export function previewTaskToggle(eventString: string) {
|
|
const [eventName, pos] = JSON.parse(eventString);
|
|
if (eventName === "task") {
|
|
return taskCycleAtPos(+pos);
|
|
}
|
|
}
|
|
|
|
async function convertListItemToTask(node: ParseTree) {
|
|
const listMark = node.children![0];
|
|
const originalMark = renderToText(listMark);
|
|
|
|
// Determine the task marker based on the original list type
|
|
let taskMarker: string;
|
|
if (originalMark.match(/^\d+\./)) {
|
|
// Numbered list: preserve the number
|
|
taskMarker = originalMark + " [ ]";
|
|
} else {
|
|
// Bullet list: use standard bullet
|
|
taskMarker = "* [ ]";
|
|
}
|
|
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: listMark.from,
|
|
to: listMark.to,
|
|
insert: taskMarker,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function removeTaskCheckbox(listItemNode: ParseTree) {
|
|
const taskNode = findNodeOfType(listItemNode, "Task");
|
|
if (!taskNode) {
|
|
console.error("No task node found in list item");
|
|
return;
|
|
}
|
|
|
|
// Task node contains: TaskMark, TaskState, and text content. Keep just list marker and content after the checkbox
|
|
const listMark = listItemNode.children![0];
|
|
const contentAfterCheckbox = taskNode.children!.slice(1); // Skip TaskMark which contains [ ]
|
|
|
|
const textContent = contentAfterCheckbox.map(renderToText).join("");
|
|
|
|
// Replace entire list item content
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: listItemNode.from!,
|
|
to: listItemNode.to!,
|
|
insert: renderToText(listMark) + textContent,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function cycleTaskState(
|
|
node: ParseTree,
|
|
removeCheckbox: boolean = false,
|
|
) {
|
|
const stateText = node.children![1].text!;
|
|
|
|
// If removeCheckbox is true and task is complete, remove checkbox entirely
|
|
if (removeCheckbox && completeStates.includes(stateText)) {
|
|
// Convert back to regular list item
|
|
const taskNode = node.parent!;
|
|
const listItemNode = taskNode.parent!;
|
|
await removeTaskCheckbox(listItemNode);
|
|
return;
|
|
}
|
|
|
|
let changeTo: string | undefined;
|
|
if (completeStates.includes(stateText)) {
|
|
changeTo = " ";
|
|
} else if (incompleteStates.includes(stateText)) {
|
|
changeTo = "x";
|
|
} else {
|
|
// Not a checkbox, but a custom state
|
|
const allStates = await queryLuaObjects<TaskStateObject>(
|
|
"taskstate",
|
|
{},
|
|
{},
|
|
);
|
|
const states = [...new Set(allStates.map((s) => s.state))];
|
|
states.sort();
|
|
// Select a next state
|
|
const currentStateIndex = states.indexOf(stateText);
|
|
if (currentStateIndex === -1) {
|
|
console.error("Unknown state", stateText);
|
|
return;
|
|
}
|
|
const nextStateIndex = (currentStateIndex + 1) % states.length;
|
|
changeTo = states[nextStateIndex];
|
|
// console.log("All possible states", states);
|
|
// return;
|
|
}
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: node.children![1].from,
|
|
to: node.children![1].to,
|
|
insert: changeTo,
|
|
},
|
|
});
|
|
|
|
const parentWikiLinks = collectNodesMatching(
|
|
node.parent!,
|
|
(n) => n.type === "WikiLinkPage",
|
|
);
|
|
for (const wikiLink of parentWikiLinks) {
|
|
const ref = wikiLink.children![0].text!;
|
|
if (ref.includes("@")) {
|
|
await updateTaskState(ref, stateText, changeTo);
|
|
}
|
|
}
|
|
|
|
await events.dispatchEvent("task:stateChange", {
|
|
from: node.parent!.from,
|
|
to: node.parent!.to,
|
|
newState: changeTo,
|
|
text: renderToText(node.parent),
|
|
});
|
|
}
|
|
|
|
export async function updateTaskState(
|
|
path: string,
|
|
oldState: string,
|
|
newState: string,
|
|
) {
|
|
const currentPath = await editor.getCurrentPath();
|
|
const ref = parseToRef(path);
|
|
|
|
if (
|
|
!ref || !ref.details || !isMarkdownPath(ref.path) ||
|
|
(ref.details.type !== "linecolumn" && ref.details.type !== "position")
|
|
) {
|
|
console.log("No position found in page ref, skipping", ref);
|
|
return;
|
|
}
|
|
|
|
if (ref.path === currentPath) {
|
|
// In current page, just update the task marker with dispatch
|
|
const editorText = await editor.getText();
|
|
|
|
const targetPos = ref.details.type === "position"
|
|
? ref.details.pos
|
|
: getOffsetFromLineColumn(
|
|
editorText,
|
|
ref.details.line,
|
|
ref.details.column,
|
|
);
|
|
|
|
// Check if the task state marker is still there
|
|
const targetText = editorText.substring(
|
|
targetPos + 1,
|
|
targetPos + 1 + oldState.length,
|
|
);
|
|
if (targetText !== oldState) {
|
|
console.error(
|
|
"Reference not a task marker, out of date?",
|
|
targetText,
|
|
);
|
|
return;
|
|
}
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: targetPos + 1,
|
|
to: targetPos + 1 + oldState.length,
|
|
insert: newState,
|
|
},
|
|
});
|
|
} else {
|
|
const pageName = getNameFromPath(ref.path);
|
|
let text = await space.readPage(pageName);
|
|
|
|
const referenceMdTree = await markdown.parseMarkdown(text);
|
|
const targetPos = ref.details.type === "position"
|
|
? ref.details.pos
|
|
: getOffsetFromLineColumn(
|
|
text,
|
|
ref.details.line,
|
|
ref.details.column,
|
|
);
|
|
|
|
// Adding +1 to immediately hit the task state node
|
|
const taskStateNode = nodeAtPos(referenceMdTree, targetPos + 1);
|
|
if (!taskStateNode || taskStateNode.type !== "TaskState") {
|
|
console.error(
|
|
"Reference not a task marker, out of date?",
|
|
taskStateNode,
|
|
);
|
|
return;
|
|
}
|
|
taskStateNode.children![1].text = newState;
|
|
text = renderToText(referenceMdTree);
|
|
await space.writePage(pageName, text);
|
|
sync.performFileSync(`${pageName}.md`);
|
|
}
|
|
}
|
|
|
|
export async function taskCycleAtPos(pos: number) {
|
|
const text = await editor.getText();
|
|
const mdTree = await markdown.parseMarkdown(text);
|
|
addParentPointers(mdTree);
|
|
|
|
let node = nodeAtPos(mdTree, pos);
|
|
if (node) {
|
|
if (node.type === "TaskMark") {
|
|
node = node.parent!;
|
|
}
|
|
if (node.type === "TaskState") {
|
|
await cycleTaskState(node, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function taskCycleCommand() {
|
|
const text = await editor.getText();
|
|
const pos = await editor.getCursor();
|
|
const tree = await markdown.parseMarkdown(text);
|
|
addParentPointers(tree);
|
|
|
|
let node = nodeAtPos(tree, pos);
|
|
if (!node) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
if (["BulletList", "Document"].includes(node.type!)) {
|
|
// Likely at the end of the line, let's back up a position
|
|
node = nodeAtPos(tree, pos - 1);
|
|
}
|
|
if (!node) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
console.log("Node", node);
|
|
const taskNode = node.type === "Task"
|
|
? node
|
|
: findParentMatching(node!, (n) => n.type === "Task");
|
|
|
|
if (taskNode) {
|
|
const taskState = findNodeOfType(taskNode!, "TaskState");
|
|
if (taskState) {
|
|
// Cycle states: [ ] -> [x] -> (remove checkbox) -> [ ]
|
|
await cycleTaskState(taskState, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Convert a bullet point to a task
|
|
const listItem = findParentMatching(node!, (n) => n.type === "ListItem");
|
|
if (!listItem) {
|
|
await editor.flashNotification("No task at cursor");
|
|
return;
|
|
}
|
|
|
|
// Check if this ListItem already contains a Task (cursor might be at beginning of line)
|
|
const existingTask = findNodeOfType(listItem, "Task");
|
|
if (existingTask) {
|
|
const taskState = findNodeOfType(existingTask, "TaskState");
|
|
if (taskState) {
|
|
await cycleTaskState(taskState, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
convertListItemToTask(listItem);
|
|
}
|
|
|
|
export async function postponeCommand() {
|
|
const text = await editor.getText();
|
|
const pos = await editor.getCursor();
|
|
const tree = await markdown.parseMarkdown(text);
|
|
addParentPointers(tree);
|
|
|
|
const node = nodeAtPos(tree, pos)!;
|
|
// We kwow node.type === DeadlineDate (due to the task context)
|
|
const date = getDeadline(node);
|
|
const option = await editor.filterBox(
|
|
"Postpone for...",
|
|
[
|
|
{ name: "a day", orderId: 1 },
|
|
{ name: "a week", orderId: 2 },
|
|
{ name: "following Monday", orderId: 3 },
|
|
],
|
|
"Select the desired time span to delay this task",
|
|
);
|
|
if (!option) {
|
|
return;
|
|
}
|
|
// Parse "naive" due date
|
|
const [yyyy, mm, dd] = date.split("-").map(Number);
|
|
// Create new naive Date object.
|
|
// `monthIndex` parameter is zero-based, so subtract 1 from parsed month.
|
|
const d = new Date(yyyy, mm - 1, dd);
|
|
switch (option.name) {
|
|
case "a day":
|
|
d.setDate(d.getDate() + 1);
|
|
break;
|
|
case "a week":
|
|
d.setDate(d.getDate() + 7);
|
|
break;
|
|
case "following Monday":
|
|
d.setDate(d.getDate() + ((7 - d.getDay() + 1) % 7 || 7));
|
|
break;
|
|
}
|
|
// console.log("New date", niceDate(d));
|
|
await editor.dispatch({
|
|
changes: {
|
|
from: node.from,
|
|
to: node.to,
|
|
insert: `📅 ${niceDate(d)}`,
|
|
},
|
|
selection: {
|
|
anchor: pos,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function removeCompletedTasksCommand() {
|
|
const tree = await markdown.parseMarkdown(await editor.getText());
|
|
addParentPointers(tree);
|
|
|
|
// Taking this ugly approach because the tree is modified in place
|
|
// Just finding and removing one task at a time and then repeating until nothing changes
|
|
while (true) {
|
|
const completedTaskNode = findNodeMatching(tree, (node) => {
|
|
return node.type === "Task" &&
|
|
["x", "X"].includes(node.children![0].children![1].text!);
|
|
});
|
|
if (completedTaskNode) {
|
|
// Ok got one, let's remove it
|
|
const listItemNode = completedTaskNode.parent!;
|
|
const bulletListNode = listItemNode.parent!;
|
|
// Remove the list item
|
|
const listItemIdx = bulletListNode.children!.indexOf(listItemNode);
|
|
let removeItems = 1;
|
|
if (bulletListNode.children![listItemIdx + 1]?.text === "\n") {
|
|
removeItems++;
|
|
}
|
|
bulletListNode.children!.splice(listItemIdx, removeItems);
|
|
} else {
|
|
// No completed tasks left, we're done
|
|
break;
|
|
}
|
|
}
|
|
|
|
await editor.setText(renderToText(tree));
|
|
}
|