mirror of
https://github.com/d7sd6u/obsidian-crosslink-advanced.git
synced 2026-07-22 05:41:20 +00:00
Init
This commit is contained in:
commit
17dfc102db
22 changed files with 2251 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# vscode
|
||||
.vscode
|
||||
.home
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
main.js
|
||||
dist
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
node_modules.bak
|
||||
.direnv
|
||||
test
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[submodule "obsidian-typings"]
|
||||
path = obsidian-typings
|
||||
url = git@github.com:d7sd6u/obsidian-typings.git
|
||||
[submodule "obsidian-reusables"]
|
||||
path = obsidian-reusables
|
||||
url = git@github.com:d7sd6u/obsidian-reusables.git
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
obsidian-typings
|
||||
node_modules
|
||||
.direnv
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
flake.lock
|
||||
165
LICENSE.md
Normal file
165
LICENSE.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
48
esbuild.config.mjs
Normal file
48
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = process.argv[3] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "dist/main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
22
eslint.config.mjs
Normal file
22
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{ allowNumber: true },
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
27
flake.lock
Normal file
27
flake.lock
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1713564160,
|
||||
"narHash": "sha256-YguPZpiejgzLEcO36/SZULjJQ55iWcjAmf3lYiyV1Fo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "bc194f70731cc5d2b046a6c1b3b15f170f05999c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "bc194f70731cc5d2b046a6c1b3b15f170f05999c",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
31
flake.nix
Normal file
31
flake.nix
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
description = "A Nix-flake-based Node.js development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?rev=bc194f70731cc5d2b046a6c1b3b15f170f05999c";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
in
|
||||
{
|
||||
devShells."${system}".default =
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in
|
||||
pkgs.mkShell {
|
||||
# create an environment with nodejs_18, pnpm, and yarn
|
||||
packages = with pkgs; [
|
||||
nodejs_18
|
||||
nodePackages.pnpm
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "node `${pkgs.nodejs}/bin/node --version`"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
1
obsidian-reusables
Submodule
1
obsidian-reusables
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit e7b9a59117755b51386c59cf68e20d6d1bbe1d3c
|
||||
1
obsidian-typings
Submodule
1
obsidian-typings
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 525118c91be1bdb129c211acc08578a1d5a666c3
|
||||
36
package.json
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "crosslink-advanced",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "./src/main.ts",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"typecheck": "tsc -noEmit -skipLibCheck",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint --fix src/",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "LGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@tsconfig/strictest": "^2.0.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^16.11.6",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"eslint": "^9.20.1",
|
||||
"obsidian": "1.7.2",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"monkey-around": "^3.0.0"
|
||||
}
|
||||
}
|
||||
1224
pnpm-lock.yaml
Normal file
1224
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
541
src/main.ts
Normal file
541
src/main.ts
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||
import {
|
||||
App,
|
||||
FileView,
|
||||
FuzzyMatch,
|
||||
FuzzySuggestModal,
|
||||
Notice,
|
||||
TFile,
|
||||
TFolder,
|
||||
View,
|
||||
setIcon,
|
||||
} from "obsidian";
|
||||
|
||||
import {
|
||||
forceFile,
|
||||
isIndexFile,
|
||||
forceFolder,
|
||||
movableFile,
|
||||
folderPrefix,
|
||||
} from "../obsidian-reusables/src/indexFiles";
|
||||
import {
|
||||
getFileParentIndexes,
|
||||
getFileIsTargetFileTagFilter,
|
||||
} from "../obsidian-reusables/src/ftags";
|
||||
import PluginWithSettings from "../obsidian-reusables/src/PluginWithSettings";
|
||||
import { DEFAULT_SETTINGS, MainPluginSettingsTab } from "./settings";
|
||||
|
||||
const allFilesFilter = (file: TFile): boolean =>
|
||||
file.extension === "md" || file.extension === "dir";
|
||||
|
||||
export default class Main extends PluginWithSettings(DEFAULT_SETTINGS) {
|
||||
override async onload() {
|
||||
await this.initSettings(MainPluginSettingsTab);
|
||||
this.addCommand({
|
||||
id: "add-ftag",
|
||||
name: "Add ftag to the note",
|
||||
icon: "folder-symlink",
|
||||
callback: this.openAddFtagModal.bind(this),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-random-inbox-item",
|
||||
name: "Open random inbox item",
|
||||
icon: "dices",
|
||||
callback: this.openRandomInboxItem.bind(this),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "rename-and-keep-as-alias",
|
||||
name: "Rename swapping for alias",
|
||||
icon: "edit",
|
||||
callback: this.renameAndKeepAsAlias.bind(this),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "remove-inbox-tag",
|
||||
name: "Remove inbox tag",
|
||||
icon: "combine",
|
||||
callback: this.removeInboxTag.bind(this),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "create-note-as-child",
|
||||
name: "Create new note as child",
|
||||
icon: "corner-down-right",
|
||||
callback: this.createNoteAsChild.bind(this),
|
||||
});
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("file-menu", (menu, file) => {
|
||||
if (file instanceof TFile && file.extension === "md") {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Rename swapping for alias")
|
||||
.setIcon("edit")
|
||||
.onClick(async () => {
|
||||
await this.renameAndKeepAsAlias(file);
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.addRibbonIcon(
|
||||
"dices",
|
||||
"Open random inbox item",
|
||||
this.openRandomInboxItem.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private async renameAndKeepAsAlias(file = this.getCurrentFile()) {
|
||||
if (!file || file.extension !== "md") return;
|
||||
|
||||
const currentName = file.getShortName();
|
||||
|
||||
await this.app.fileManager.promptForFileRename(file);
|
||||
|
||||
function isObj(v: unknown) {
|
||||
return typeof v === "object" && !!v;
|
||||
}
|
||||
function hasAliases(v: unknown): v is { aliases?: string[] } {
|
||||
return (
|
||||
isObj(v) &&
|
||||
(("aliases" in v && Array.isArray(v.aliases)) ||
|
||||
!("aliases" in v))
|
||||
);
|
||||
}
|
||||
await this.app.fileManager.processFrontMatter(file, (v) => {
|
||||
if (!hasAliases(v)) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
v.aliases ||= [];
|
||||
v.aliases.push(currentName);
|
||||
});
|
||||
}
|
||||
|
||||
public async removeInboxTag() {
|
||||
const indexFile = this.app.workspace.getActiveFile();
|
||||
const currentFile = movableFile(indexFile);
|
||||
if (!currentFile || !indexFile) return;
|
||||
|
||||
if (currentFile.parent?.path !== this.settings.inbox) return;
|
||||
|
||||
const tags = this.app.vault
|
||||
.getFiles()
|
||||
.filter(getFileIsTargetFileTagFilter(currentFile, this.app));
|
||||
|
||||
const nonInboxTag = tags.find(
|
||||
(v) => movableFile(v).path !== this.settings.inbox,
|
||||
);
|
||||
|
||||
if (nonInboxTag) {
|
||||
await this.removeFileSymlinkTo(indexFile, nonInboxTag);
|
||||
const { folder } = await forceFolder(nonInboxTag, this.app);
|
||||
await this.app.fileManager.renameFile(
|
||||
currentFile,
|
||||
folderPrefix(folder) + currentFile.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public openRandomInboxItem() {
|
||||
const res = this.app.vault.getAbstractFileByPath(this.settings.inbox);
|
||||
const currentFile = this.app.workspace.getActiveFile();
|
||||
if (res instanceof TFolder) {
|
||||
const children = res.children.flatMap((child) => {
|
||||
if (child instanceof TFile && child.extension !== "dir")
|
||||
return child;
|
||||
if (child instanceof TFolder)
|
||||
return child.children.filter(isIndexFile).slice(0, 1);
|
||||
return [];
|
||||
});
|
||||
const chosen = children
|
||||
.filter((v) => v !== currentFile)
|
||||
.shuffle()[0];
|
||||
if (chosen) {
|
||||
void this.app.workspace.openLinkText(chosen.path, "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
private getCurrentFile() {
|
||||
const view = this.app.workspace.activeLeaf?.view;
|
||||
const isFileView = (v: View): v is FileView => "file" in v;
|
||||
if (!view || !isFileView(view)) return null;
|
||||
|
||||
return view.file;
|
||||
}
|
||||
public openAddFtagModal() {
|
||||
const editedFile = this.getCurrentFile();
|
||||
if (!editedFile) return;
|
||||
const getDefaultPlaceholder = () =>
|
||||
`Choose tags from all files for "${editedFile.basename}"`;
|
||||
|
||||
const parents = new Set(getFileParentIndexes(editedFile, this.app));
|
||||
const allFilesExceptParents = (v: TFile) =>
|
||||
allFilesFilter(v) && !parents.has(v) && v !== editedFile;
|
||||
const modal = new FileAndDirChooser(this.app, {
|
||||
filter: allFilesExceptParents,
|
||||
inbox: this.settings.inbox,
|
||||
onSelect: (file) => void this.addFtag(editedFile, file),
|
||||
customFileActions: [
|
||||
{
|
||||
icon: "tags",
|
||||
text: "Show this file's tags",
|
||||
cb: (_, selectedFile) => {
|
||||
modal.setPlaceholder(
|
||||
`Choose tags from "${selectedFile.basename}" for "${editedFile.basename}"`,
|
||||
);
|
||||
modal.setFilter(
|
||||
getFileIsTargetFileTagFilter(
|
||||
selectedFile,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
modal.clearQuery();
|
||||
},
|
||||
},
|
||||
],
|
||||
customActions: [
|
||||
{
|
||||
icon: "rotate-ccw",
|
||||
text: "Reset filters and target file",
|
||||
cb: () => {
|
||||
modal.setPlaceholder(getDefaultPlaceholder());
|
||||
modal.setFilter(allFilesExceptParents);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
modal.setPlaceholder(getDefaultPlaceholder());
|
||||
modal.open();
|
||||
}
|
||||
|
||||
private addFtag = async (file: TFile, ftag: TFile) => {
|
||||
if (movableFile(file).parent?.path === this.settings.inbox) {
|
||||
const movedFile = isIndexFile(file) ? file.parent! : file;
|
||||
const { folder } = await forceFolder(ftag, this.app);
|
||||
|
||||
await this.app.fileManager.renameFile(
|
||||
movedFile,
|
||||
`${folder.path}/${movedFile.name}`,
|
||||
);
|
||||
|
||||
new Notice(`Added ftag ${ftag.basename} by moving`);
|
||||
} else {
|
||||
const { index } = await forceFolder(ftag, this.app);
|
||||
const forcedIndex = await forceFile(index, this.app);
|
||||
|
||||
await this.addFileSymlinkTo(file, forcedIndex);
|
||||
new Notice(`Added ftag ${ftag.basename} via link`);
|
||||
}
|
||||
};
|
||||
|
||||
public async createNoteAsChild() {
|
||||
const currentFile = this.app.workspace.getActiveFile();
|
||||
if (!currentFile) return;
|
||||
const { folder } = await forceFolder(currentFile, this.app);
|
||||
const note = await this.app.fileManager.createNewMarkdownFile(
|
||||
folder,
|
||||
"Untitled",
|
||||
"",
|
||||
);
|
||||
await this.app.workspace.openLinkText(note.path, "/", "tab", {
|
||||
active: true,
|
||||
eState: { rename: "all" },
|
||||
});
|
||||
}
|
||||
|
||||
private async removeFileSymlinkTo(file: TFile, forcedIndex: TFile) {
|
||||
await this.app.fileManager.processFrontMatter(
|
||||
forcedIndex,
|
||||
(frontmatter: { symlinks?: unknown }) => {
|
||||
if (Array.isArray(frontmatter.symlinks)) {
|
||||
frontmatter.symlinks = frontmatter.symlinks.filter(
|
||||
(v: string) =>
|
||||
this.app.metadataCache.getFirstLinkpathDest(
|
||||
v.slice(2, -2),
|
||||
forcedIndex.path,
|
||||
) !== file,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
private async addFileSymlinkTo(file: TFile, forcedIndex: TFile) {
|
||||
const fileLink = this.app.fileManager
|
||||
.generateMarkdownLink(file, file.path)
|
||||
.replace(/^!/, "");
|
||||
await this.app.fileManager.processFrontMatter(
|
||||
forcedIndex,
|
||||
(frontmatter: { symlinks?: unknown }) => {
|
||||
if (!frontmatter.symlinks) {
|
||||
frontmatter.symlinks = [fileLink];
|
||||
} else if (Array.isArray(frontmatter.symlinks)) {
|
||||
frontmatter.symlinks = [
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
...frontmatter.symlinks.filter((v) => v !== fileLink),
|
||||
fileLink,
|
||||
];
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface Options {
|
||||
filter: (file: TFile) => boolean;
|
||||
onSelect: (file: TFile) => void;
|
||||
inbox: string;
|
||||
customFileActions?: {
|
||||
icon: string;
|
||||
text: string;
|
||||
cb: (e: MouseEvent, file: TFile) => void;
|
||||
}[];
|
||||
customActions?: {
|
||||
icon: string;
|
||||
text: string;
|
||||
cb: (e: MouseEvent, query: string) => void;
|
||||
}[];
|
||||
}
|
||||
export class FileAndDirChooser extends FuzzySuggestModal<TFile> {
|
||||
public setFilter(filter: Options["filter"]) {
|
||||
this.filter = filter;
|
||||
this.recalculateSuggestions();
|
||||
}
|
||||
private inbox: string;
|
||||
constructor(app: App, opts: Options) {
|
||||
super(app);
|
||||
this.inbox = opts.inbox;
|
||||
this.filter = opts.filter;
|
||||
this.onSelect = opts.onSelect;
|
||||
this.customFileActions = opts.customFileActions ?? [];
|
||||
this.customActions = opts.customActions ?? [];
|
||||
|
||||
this.setInstructions([
|
||||
{ command: "↑↓", purpose: "to navigate" },
|
||||
{
|
||||
command: "shift ↵",
|
||||
purpose: "to create new note and use as new tag",
|
||||
},
|
||||
{ command: "ctrl ↵", purpose: "to use selected file as new tag" },
|
||||
{
|
||||
command: "alt ↵",
|
||||
purpose: "to use selected file as new tag and clear",
|
||||
},
|
||||
{ command: "↵", purpose: "to use file as new tag and close" },
|
||||
{ command: "esc", purpose: "to dismiss" },
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
this.inputEl.addEventListener("keydown", async (evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
if (
|
||||
(evt.shiftKey || this.isWithoutShownSuggestions) &&
|
||||
!this.isEmptyQuery
|
||||
) {
|
||||
await this.createNoteFromQuery({
|
||||
close: !evt.ctrlKey,
|
||||
clear: evt.altKey,
|
||||
});
|
||||
} else if (evt.ctrlKey || evt.altKey) {
|
||||
this.selectHighlightedFile({ clear: evt.altKey });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cta =
|
||||
this.inputEl.parentElement!.querySelector(".prompt-input-cta");
|
||||
|
||||
const addButton = (
|
||||
icon: string,
|
||||
text: string,
|
||||
cb: (e: MouseEvent) => void,
|
||||
phoneOnly?: boolean,
|
||||
) => {
|
||||
const button = createEl(
|
||||
"button",
|
||||
"clickable-icon " + (phoneOnly ? "only-phone" : ""),
|
||||
(e) => {
|
||||
setIcon(e, icon);
|
||||
e.setAttribute("aria-label", text);
|
||||
e.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
cb(e);
|
||||
};
|
||||
},
|
||||
);
|
||||
cta?.appendChild(button);
|
||||
};
|
||||
|
||||
addButton("file-plus", "Create note with given name", (e) => {
|
||||
void this.createNoteFromQuery({
|
||||
close: !e.ctrlKey && !e.altKey,
|
||||
clear: e.altKey,
|
||||
});
|
||||
});
|
||||
|
||||
addButton(
|
||||
"file-stack",
|
||||
"Create note and clear input",
|
||||
() => {
|
||||
void this.createNoteFromQuery({
|
||||
close: false,
|
||||
clear: true,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
for (const conf of this.customActions) {
|
||||
addButton(conf.icon, conf.text, (e) => {
|
||||
conf.cb(e, this.inputEl.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
private onSelect: Options["onSelect"];
|
||||
private filter: Options["filter"];
|
||||
private customFileActions: NonNullable<Options["customFileActions"]>;
|
||||
private customActions: NonNullable<Options["customActions"]>;
|
||||
|
||||
private async createNoteFromQuery({
|
||||
close,
|
||||
clear,
|
||||
}: {
|
||||
close?: boolean;
|
||||
clear?: boolean;
|
||||
}) {
|
||||
const newNote =
|
||||
await this.app.fileManager.createNewMarkdownFileFromLinktext(
|
||||
this.currentQuery,
|
||||
this.inbox,
|
||||
);
|
||||
this.onSelect(newNote);
|
||||
if (close) {
|
||||
this.close();
|
||||
}
|
||||
if (clear) {
|
||||
this.clearQuery();
|
||||
}
|
||||
}
|
||||
private selectHighlightedFile({ clear }: { clear: boolean }) {
|
||||
const file = this.highlightedFile;
|
||||
if (file) {
|
||||
this.onSelect(file);
|
||||
if (clear) {
|
||||
this.clearQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private get highlightedFile() {
|
||||
if (
|
||||
this.hiddenChooser.selectedItem != null &&
|
||||
this.hiddenChooser.values
|
||||
) {
|
||||
return this.hiddenChooser.values[this.hiddenChooser.selectedItem]!
|
||||
.item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
private get isEmptyQuery() {
|
||||
return this.inputEl.value.trim().length === 0;
|
||||
}
|
||||
private get isWithoutShownSuggestions() {
|
||||
return this.hiddenChooser.suggestions.length === 0;
|
||||
}
|
||||
private get currentQuery() {
|
||||
return this.inputEl.value;
|
||||
}
|
||||
private get hiddenChooser() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
||||
const chooser = (this as any).chooser as {
|
||||
selectedItem: number | null;
|
||||
values: { item: TFile }[] | null;
|
||||
suggestions: unknown[];
|
||||
};
|
||||
return chooser;
|
||||
}
|
||||
|
||||
public clearQuery() {
|
||||
this.inputEl.value = "";
|
||||
this.recalculateSuggestions();
|
||||
}
|
||||
private recalculateSuggestions() {
|
||||
this.inputEl.dispatchEvent(new InputEvent("input"));
|
||||
}
|
||||
|
||||
getItems(): TFile[] {
|
||||
const lastOpenedFiles =
|
||||
this.app.workspace.recentFileTracker.lastOpenFiles;
|
||||
const set = new Set(lastOpenedFiles);
|
||||
const files = this.app.vault.getFiles();
|
||||
return files.filter(this.filter).sort((b, a) => {
|
||||
if (set.has(a.path) && !set.has(b.path)) return 1;
|
||||
if (!set.has(a.path) && set.has(b.path)) return -1;
|
||||
if (!set.has(a.path) && !set.has(b.path))
|
||||
return a.stat.mtime - b.stat.mtime;
|
||||
return -(
|
||||
lastOpenedFiles.indexOf(a.path) -
|
||||
lastOpenedFiles.indexOf(b.path)
|
||||
);
|
||||
});
|
||||
}
|
||||
override renderSuggestion(item: FuzzyMatch<TFile>, el: HTMLElement): void {
|
||||
(FuzzySuggestModal<TFile>).prototype.renderSuggestion.apply(this, [
|
||||
item,
|
||||
el,
|
||||
]);
|
||||
const content = el.createDiv("suggestion-content");
|
||||
const title = content.createDiv("suggestion-title");
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child === content) continue;
|
||||
title.appendChild(child);
|
||||
}
|
||||
el.classList.add("mod-complex");
|
||||
const aux = el.createDiv("suggestion-aux");
|
||||
const createAuxButton = (
|
||||
icon: string,
|
||||
text: string,
|
||||
cb: (e: MouseEvent) => void,
|
||||
) => {
|
||||
const flair = aux.createSpan("suggestion-flair");
|
||||
flair.setAttribute("aria-label", text);
|
||||
flair.onclick = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
cb(evt);
|
||||
};
|
||||
setIcon(flair, icon);
|
||||
};
|
||||
createAuxButton(
|
||||
"mouse-pointer-click",
|
||||
"Choose note without closing modal",
|
||||
() => {
|
||||
this.selectHighlightedFile({ clear: false });
|
||||
},
|
||||
);
|
||||
createAuxButton("search-slash", "Choose note and clear query", () => {
|
||||
this.selectHighlightedFile({ clear: true });
|
||||
});
|
||||
for (const opts of this.customFileActions) {
|
||||
createAuxButton(opts.icon, opts.text, (e) => {
|
||||
opts.cb(e, item.item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getItemText(file: TFile): string {
|
||||
const isIndex = file.basename === file.parent?.name;
|
||||
if (isIndex) return file.parent?.path ?? file.path;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const aliases = this.app.metadataCache.getCache(file.path)
|
||||
?.frontmatter?.["aliases"];
|
||||
if (Array.isArray(aliases))
|
||||
return file.path + "\n" + aliases.join(", ");
|
||||
return file.path;
|
||||
}
|
||||
|
||||
onChooseItem(file: TFile) {
|
||||
this.onSelect(file);
|
||||
}
|
||||
}
|
||||
11
src/manifest.json
Normal file
11
src/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"id": "crosslink-advanced",
|
||||
"name": "Crosslink Advanced",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Allows to tag files using folders and symlinks system.",
|
||||
"author": "d7sd6u",
|
||||
"authorUrl": "https://github.com/d7sd6u",
|
||||
"fundingUrl": "https://ko-fi.com/d7sd6u",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
36
src/settings.ts
Normal file
36
src/settings.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import Main from "./main";
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
inbox: "Uncategorized",
|
||||
};
|
||||
export class MainPluginSettingsTab extends PluginSettingTab {
|
||||
constructor(
|
||||
app: App,
|
||||
override plugin: Main,
|
||||
) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
const options = Object.fromEntries(
|
||||
this.app.vault.getAllFolders().map((v) => [v.path, v.path]),
|
||||
);
|
||||
options["/"] = "/";
|
||||
new Setting(containerEl)
|
||||
.setName("Inbox folder")
|
||||
.setDesc("Folder where notes without explicit ftags are stored")
|
||||
.addDropdown((textArea) => {
|
||||
textArea
|
||||
.addOptions(options)
|
||||
.setValue(this.plugin.settings.inbox)
|
||||
.onChange(async (v) => {
|
||||
this.plugin.settings.inbox = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
20
src/styles.css
Normal file
20
src/styles.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
|
||||
*/
|
||||
|
||||
.prompt-input-cta {
|
||||
padding: var(--size-4-3);
|
||||
gap: var(--size-4-1);
|
||||
display: flex;
|
||||
}
|
||||
.only-phone {
|
||||
display: none;
|
||||
}
|
||||
.is-phone .only-phone {
|
||||
display: initial;
|
||||
}
|
||||
3
src/versions.json
Normal file
3
src/versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "obsidian-typings/src/**/*.d.ts"]
|
||||
}
|
||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||
Loading…
Reference in a new issue