mirror of
https://github.com/nathonius/obsidian-github-link.git
synced 2026-07-22 09:20:25 +00:00
Compare commits
111 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5ad979881 | ||
|
|
4366eebfb6 | ||
|
|
0f809ae0a9 | ||
|
|
2d9dba0d43 | ||
|
|
568b4d4335 | ||
|
|
37ff39dbc5 | ||
|
|
ceab053dca | ||
|
|
dd3bfaa15f | ||
|
|
1b4317c3b9 | ||
|
|
3289da5a1d | ||
|
|
69f2d2a08e | ||
|
|
a4d1cbc7bf | ||
|
|
fef1eb84a0 | ||
|
|
6e143d00ce | ||
|
|
ebb30a8940 | ||
|
|
020fcead39 | ||
|
|
088597bb8c | ||
|
|
ed4bc6961f | ||
|
|
0878512754 | ||
|
|
82f5dadf83 | ||
|
|
353b95ec34 | ||
|
|
cd7ebaf679 | ||
|
|
84eeedb9ba | ||
|
|
cae9718a6e | ||
|
|
640a042946 | ||
|
|
05cbb3dce2 | ||
|
|
9cdfba024d | ||
|
|
41f6c40bb3 | ||
|
|
7679a149be | ||
|
|
bf0572256a | ||
|
|
0257f4909f | ||
|
|
ea163a31e0 | ||
|
|
42d35e4c70 | ||
|
|
792ca07395 | ||
|
|
10aabb2a34 | ||
|
|
b355284cf5 | ||
|
|
be2aa15f32 | ||
|
|
0b38bd17f0 | ||
|
|
7aa0c14e01 | ||
|
|
061b8bdab9 | ||
|
|
4919fe23eb | ||
|
|
2d42cb3edf | ||
|
|
8d495d990d | ||
|
|
0fb4e4dbd0 | ||
|
|
43728122a6 | ||
|
|
9d495329d6 | ||
|
|
97b1f8a34f | ||
|
|
cd0f34086c | ||
|
|
9b7997cc30 | ||
|
|
12a0b6e332 | ||
|
|
376174907a | ||
|
|
968dfbcc8a | ||
|
|
309177e07a | ||
|
|
613c53b96b | ||
|
|
1f43fba5dd | ||
|
|
5aab216db0 | ||
|
|
2b270db569 | ||
|
|
5a9766bf30 | ||
|
|
426078dc2e | ||
|
|
5c106a9f1e | ||
|
|
aad1320bd8 | ||
|
|
85093e223a | ||
|
|
3b98818272 | ||
|
|
982ac30a89 | ||
|
|
f2eee1f178 | ||
|
|
9b59e6abc6 | ||
|
|
4e5d4f07d5 | ||
|
|
5f65a14c24 | ||
|
|
a9ff3857c8 | ||
|
|
5640f400ea | ||
|
|
d0aff85157 | ||
|
|
50f2e7b14a | ||
|
|
4cad2f634f | ||
|
|
d0496b1600 | ||
|
|
f5eca6a566 | ||
|
|
99af75cffe | ||
|
|
2af4f73d51 | ||
|
|
cb64714466 | ||
|
|
5327ed61ef | ||
|
|
1c6d1a28ad | ||
|
|
9e53b68bc5 | ||
|
|
e5aaebc1bb | ||
|
|
b7ac55dc59 | ||
|
|
6f2a97ebe1 | ||
|
|
f204ddd71d | ||
|
|
0d11e02991 | ||
|
|
80bb501282 | ||
|
|
0aeff6cd46 | ||
|
|
a1724f2ca2 | ||
|
|
f670708c68 | ||
|
|
c2cc1dea9c | ||
|
|
d717a93976 | ||
|
|
dd32f7299f | ||
|
|
247997c431 | ||
|
|
ea2ee0608c | ||
|
|
1a3059512a | ||
|
|
65ab8c207e | ||
|
|
38091a52e8 | ||
|
|
6be7a0131d | ||
|
|
4ca024edf4 | ||
|
|
8ef8163425 | ||
|
|
fae232cba4 | ||
|
|
7584ec8470 | ||
|
|
8cfc5690f1 | ||
|
|
7aa5aee6ab | ||
|
|
5cc35c318d | ||
|
|
2600878b0b | ||
|
|
810a15646e | ||
|
|
0a15cd53a3 | ||
|
|
afe8a2ed0c | ||
|
|
ddd76872c0 |
56 changed files with 7806 additions and 921 deletions
59
.all-contributorsrc
Normal file
59
.all-contributorsrc
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"projectName": "obsidian-github-link",
|
||||
"projectOwner": "nathonius",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"commitType": "docs",
|
||||
"commitConvention": "angular",
|
||||
"contributorsPerLine": 7,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "nathonius",
|
||||
"name": "Nathan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4851889?v=4",
|
||||
"profile": "http://nathan-smith.org",
|
||||
"contributions": [
|
||||
"code",
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "agriffis",
|
||||
"name": "Aron Griffis",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/50637?v=4",
|
||||
"profile": "https://arongriffis.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "nikclayton",
|
||||
"name": "Nik Clayton",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/773100?v=4",
|
||||
"profile": "https://github.com/nikclayton",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "joshleaves",
|
||||
"name": "Arnaud 'red' Rouyer",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1074760?v=4",
|
||||
"profile": "https://tech.dreamleaves.org",
|
||||
"contributions": [
|
||||
"code",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "tmcw",
|
||||
"name": "Tom MacWright",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/32314?v=4",
|
||||
"profile": "https://macwright.com/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
32
.eslintrc
32
.eslintrc
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": { "node": true },
|
||||
"plugins": ["@typescript-eslint", "unused-imports"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-this-alias": "warn",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
// replaced by 'unused-imports/no-unused-vars'
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.github/workflows/code-check.yml
vendored
5
.github/workflows/code-check.yml
vendored
|
|
@ -6,13 +6,14 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
uses: "./.github/workflows/build.yml"
|
||||
lint:
|
||||
check:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Lint
|
||||
- name: Lint and Test
|
||||
run: |
|
||||
npm install
|
||||
npm run lint
|
||||
npm run test
|
||||
|
|
|
|||
46
.gitignore
vendored
46
.gitignore
vendored
|
|
@ -1,22 +1,24 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# 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
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# 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
|
||||
|
||||
coverage/
|
||||
|
|
|
|||
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -16,6 +16,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
<!-- ## Unreleased -->
|
||||
|
||||
## [1.2.0] - 2025-03-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Issue number is always shown on a tag for an issue
|
||||
|
||||
## [1.1.0] - 2025-02-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Show rich metadata about filenames in links
|
||||
- A manual input for all slider settings that allows settings outside of the normal bounds
|
||||
|
||||
## [1.0.3] - 2025-01-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Allow linking directly to single files, and generally improved reliability of regex. Thanks @joshleaves!
|
||||
|
||||
## [1.0.2] - 2024-08-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Better detection of urls that have display text and should not be rendered as tags
|
||||
- More graceful handling of invalid URLs
|
||||
- Don't attempt to render a tag for certain known special GitHub URLs
|
||||
|
||||
## [1.0.0] - 2024-07-08
|
||||
|
||||
🔥 First major version update!
|
||||
|
||||
### Changed
|
||||
|
||||
- Tables with multiple pages of results can now show a paginator to fetch other pages
|
||||
- Better caching for all request types
|
||||
- Support for new columns: labels, assignee
|
||||
- Query for issues from a given organization
|
||||
- ⚠️ BREAKING CHANGE: Plugin's GitHub API has changed; it was, and remains undocumented so use at your own risk
|
||||
- ⚠️ BREAKING CHANGE: Users on version 0.5.1 or earlier may lose their account configurations upon updating.
|
||||
|
||||
### Fixed
|
||||
|
||||
- _Much_ better inline tag behavior in live preview mode! ⚡ No more stuck tags, and tags will not render inside code blocks.
|
||||
- Author and assignee columns will not overflow their containers
|
||||
|
||||
## [0.7.2] - 2024-06-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Author column for tables actually links to the author on github
|
||||
- Link all have `target="_blank"` set so that they do not close popout windows
|
||||
|
||||
## [0.7.1] - 2024-04-21
|
||||
|
||||
### Fixed
|
||||
|
|
@ -43,7 +95,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Added a new contributing document
|
||||
- Custom queries include a link to view the query results on GitHub
|
||||
|
||||
|
||||
## [0.5.1] - 2024-03-16
|
||||
|
||||
### Changed
|
||||
|
|
@ -103,6 +154,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
Initial release.
|
||||
|
||||
[1.2.0]: https://github.com/nathonius/obsidian-github-link/compare/1.1.0...1.2.0
|
||||
[1.1.0]: https://github.com/nathonius/obsidian-github-link/compare/1.0.3...1.1.0
|
||||
[1.0.3]: https://github.com/nathonius/obsidian-github-link/compare/1.0.2...1.0.3
|
||||
[1.0.2]: https://github.com/nathonius/obsidian-github-link/compare/1.0.0...1.0.2
|
||||
[1.0.0]: https://github.com/nathonius/obsidian-github-link/compare/0.7.2...1.0.0
|
||||
[0.7.2]: https://github.com/nathonius/obsidian-github-link/compare/0.7.1...0.7.2
|
||||
[0.7.1]: https://github.com/nathonius/obsidian-github-link/compare/0.7.0...0.7.1
|
||||
[0.7.0]: https://github.com/nathonius/obsidian-github-link/compare/0.6.0...0.7.0
|
||||
[0.6.0]: https://github.com/nathonius/obsidian-github-link/compare/0.5.1...0.6.0
|
||||
|
|
|
|||
42
LICENSE
42
LICENSE
|
|
@ -1,24 +1,26 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
MIT NON-AI License
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
Copyright (c) 2024, Nathan Smith
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
In addition, the following restrictions apply:
|
||||
|
||||
1. The Software and any modifications made to it may not be used for the purpose of training or improving machine learning algorithms,
|
||||
including but not limited to artificial intelligence, natural language processing, or data mining. This condition applies to any derivatives,
|
||||
modifications, or updates based on the Software code. Any usage of the Software in an AI-training dataset is considered a breach of this License.
|
||||
|
||||
2. The Software may not be included in any dataset used for training or improving machine learning algorithms,
|
||||
including but not limited to artificial intelligence, natural language processing, or data mining.
|
||||
|
||||
3. Any person or organization found to be in violation of these restrictions will be subject to legal action and may be held liable
|
||||
for any damages resulting from such use.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
|
||||
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -1,11 +1,14 @@
|
|||
# GitHub Link
|
||||
|
||||
[](https://github.com/nathonius/obsidian-github-link/wiki)
|
||||
[](https://sonarcloud.io/summary/new_code?id=nathonius_obsidian-github-link)
|
||||
[](https://sonarcloud.io/summary/new_code?id=nathonius_obsidian-github-link) [](#contributors)
|
||||
|
||||
**Obsidian + GitHub ❤️**
|
||||
|
||||
Transform boring GitHub links in notes into tags with rich content from GitHub, and query issues and pull requests within your notes.
|
||||
Inject rich content from GitHub into your notes:
|
||||
|
||||
- GitHub links are transformed into tags showing issue and pull request titles, statuses, etc
|
||||
- Use codeblocks to keep an up-to-date table of GitHub data inside a note
|
||||
|
||||
## Use
|
||||
|
||||
|
|
@ -33,3 +36,33 @@ This produces a table of results that refreshes upon opening the note.
|
|||

|
||||
|
||||
See the [documentation](https://github.com/nathonius/obsidian-github-link/wiki) for more info.
|
||||
|
||||
## Updates
|
||||
|
||||
This project adheres to [semantic versioning](https://semver.org/). See the project [changelog](CHANGELOG.md) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for info on contributing to the project.
|
||||
|
||||
### Contributors
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="http://nathan-smith.org"><img src="https://avatars.githubusercontent.com/u/4851889?v=4?s=100" width="100px;" alt="Nathan"/><br /><sub><b>Nathan</b></sub></a><br /><a href="#code-nathonius" title="Code">💻</a> <a href="#doc-nathonius" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://arongriffis.com"><img src="https://avatars.githubusercontent.com/u/50637?v=4?s=100" width="100px;" alt="Aron Griffis"/><br /><sub><b>Aron Griffis</b></sub></a><br /><a href="#code-agriffis" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nikclayton"><img src="https://avatars.githubusercontent.com/u/773100?v=4?s=100" width="100px;" alt="Nik Clayton"/><br /><sub><b>Nik Clayton</b></sub></a><br /><a href="#bug-nikclayton" title="Bug reports">🐛</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://tech.dreamleaves.org"><img src="https://avatars.githubusercontent.com/u/1074760?v=4?s=100" width="100px;" alt="Arnaud 'red' Rouyer"/><br /><sub><b>Arnaud 'red' Rouyer</b></sub></a><br /><a href="#code-joshleaves" title="Code">💻</a> <a href="#test-joshleaves" title="Tests">⚠️</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://macwright.com/"><img src="https://avatars.githubusercontent.com/u/32314?v=4?s=100" width="100px;" alt="Tom MacWright"/><br /><sub><b>Tom MacWright</b></sub></a><br /><a href="#code-tmcw" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
|
|
|||
15
__mocks__/obsidian.ts
Normal file
15
__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { AppMock } from "./obsidian/App";
|
||||
import { PluginMock } from "./obsidian/Plugin";
|
||||
import { ModalMock } from "./obsidian/Modal";
|
||||
import { PluginSettingTabMock } from "./obsidian/PluginSettingTab";
|
||||
import { NoticeMock } from "./obsidian/Notice";
|
||||
import { setIconMock } from "./obsidian/setIcon";
|
||||
|
||||
module.exports = {
|
||||
App: AppMock,
|
||||
Plugin: PluginMock,
|
||||
Modal: ModalMock,
|
||||
PluginSettingTab: PluginSettingTabMock,
|
||||
Notice: NoticeMock,
|
||||
setIcon: setIconMock,
|
||||
};
|
||||
25
__mocks__/obsidian/App.ts
Normal file
25
__mocks__/obsidian/App.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { App, FileManager, Keymap, MetadataCache, Scope, UserEvent, Vault, Workspace } from "obsidian";
|
||||
|
||||
export class AppMock implements App {
|
||||
get keymap(): Keymap {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get scope(): Scope {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get workspace(): Workspace {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get vault(): Vault {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get metadataCache(): MetadataCache {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get fileManager(): FileManager {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get lastEvent(): UserEvent | null {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
}
|
||||
33
__mocks__/obsidian/Modal.ts
Normal file
33
__mocks__/obsidian/Modal.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { App, Scope, Modal } from "obsidian";
|
||||
|
||||
export class ModalMock implements Modal {
|
||||
constructor(public app: App) {}
|
||||
get scope(): Scope {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get containerEl(): HTMLElement {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get modalEl(): HTMLElement {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get titleEl(): HTMLElement {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
get contentEl(): HTMLElement {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
shouldRestoreSelection: boolean = false;
|
||||
open(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
close(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
onOpen(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
onClose(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
13
__mocks__/obsidian/Notice.ts
Normal file
13
__mocks__/obsidian/Notice.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/* eslint-disable unused-imports/no-unused-vars */
|
||||
import type { Notice } from "obsidian";
|
||||
|
||||
export class NoticeMock implements Notice {
|
||||
constructor(message: string | DocumentFragment, duration?: number | undefined) {}
|
||||
noticeEl!: HTMLElement;
|
||||
setMessage(message: string | DocumentFragment): this {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
hide(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
120
__mocks__/obsidian/Plugin.ts
Normal file
120
__mocks__/obsidian/Plugin.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/* eslint-disable unused-imports/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { jest } from "@jest/globals";
|
||||
import type { Editor } from "codemirror";
|
||||
import type {
|
||||
App,
|
||||
Command,
|
||||
Component,
|
||||
EditorSuggest,
|
||||
EventRef,
|
||||
HoverLinkSource,
|
||||
ObsidianProtocolHandler,
|
||||
Plugin,
|
||||
PluginManifest,
|
||||
ViewCreator,
|
||||
} from "obsidian";
|
||||
|
||||
export class PluginMock implements Plugin {
|
||||
public data: any = undefined;
|
||||
public intervals: number[] = [];
|
||||
public addSettingTab = jest.fn();
|
||||
public registerEditorExtension = jest.fn();
|
||||
public registerInterval = jest.fn((interval: number) => {
|
||||
this.intervals.push(interval);
|
||||
return interval;
|
||||
});
|
||||
public registerMarkdownCodeBlockProcessor = jest.fn(() => {
|
||||
return jest.fn(() => Promise.resolve());
|
||||
});
|
||||
public registerMarkdownPostProcessor = jest.fn(() => {
|
||||
return jest.fn(() => Promise.resolve());
|
||||
});
|
||||
|
||||
constructor(
|
||||
public app: App,
|
||||
public manifest: PluginManifest,
|
||||
) {}
|
||||
|
||||
addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
addStatusBarItem(): HTMLElement {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
addCommand(command: Command): Command {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerView(type: string, viewCreator: ViewCreator): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerHoverLinkSource(id: string, info: HoverLinkSource): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerExtensions(extensions: string[], viewType: string): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
registerCodeMirror(callback: (cm: Editor) => any): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
registerObsidianProtocolHandler(action: string, handler: ObsidianProtocolHandler): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerEditorSuggest(editorSuggest: EditorSuggest<any>): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
loadData(): Promise<any> {
|
||||
return Promise.resolve(this.data);
|
||||
}
|
||||
saveData(data: any): Promise<void> {
|
||||
this.data = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
load(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
onload(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
unload(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
onunload(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
addChild<T extends Component>(component: T): T {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
removeChild<T extends Component>(component: T): T {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
register(cb: () => any): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerEvent(eventRef: EventRef): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
registerDomEvent<K extends keyof WindowEventMap>(
|
||||
el: Window,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: WindowEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void;
|
||||
registerDomEvent<K extends keyof DocumentEventMap>(
|
||||
el: Document,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: DocumentEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void;
|
||||
registerDomEvent<K extends keyof HTMLElementEventMap>(
|
||||
el: HTMLElement,
|
||||
type: K,
|
||||
callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void;
|
||||
registerDomEvent(el: unknown, type: unknown, callback: unknown, options?: unknown): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
17
__mocks__/obsidian/PluginSettingTab.ts
Normal file
17
__mocks__/obsidian/PluginSettingTab.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { App, PluginSettingTab, Plugin } from "obsidian";
|
||||
|
||||
export class PluginSettingTabMock implements PluginSettingTab {
|
||||
constructor(
|
||||
public app: App,
|
||||
public plugin: Plugin,
|
||||
) {}
|
||||
get containerEl(): HTMLElement {
|
||||
throw new Error("Not implemented.");
|
||||
}
|
||||
display() {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
hide() {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
5
__mocks__/obsidian/setIcon.ts
Normal file
5
__mocks__/obsidian/setIcon.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function setIconMock(parent: HTMLElement, iconId: string): void {
|
||||
const icon = document.createElement("span");
|
||||
icon.innerText = iconId;
|
||||
parent.appendChild(icon);
|
||||
}
|
||||
81
__mocks__/queue.ts
Normal file
81
__mocks__/queue.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { EventListenerOrEventListenerObject, EventsMap, QueueEvent, QueueWorker } from "queue";
|
||||
import type Queue from "queue";
|
||||
import type { Options } from "queue";
|
||||
|
||||
export default class QueueMock implements Queue {
|
||||
concurrency: number;
|
||||
timeout: number;
|
||||
autostart: boolean;
|
||||
results: any[] | null;
|
||||
length: number = 0;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.concurrency = options.concurrency ?? 1;
|
||||
this.timeout = options.timeout ?? -1;
|
||||
this.autostart = options.autostart ?? false;
|
||||
this.results = options.results ?? [];
|
||||
}
|
||||
|
||||
push(...workers: QueueWorker[]): number {
|
||||
for (const worker of workers) {
|
||||
void worker();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
unshift(...workers: QueueWorker[]): number {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
splice(start: number, deleteCount?: number | undefined): Queue;
|
||||
splice(start: number, deleteCount: number, ...workers: QueueWorker[]): Queue;
|
||||
splice(start: unknown, deleteCount?: unknown, ...rest: unknown[]): Queue {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
pop(): QueueWorker | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
shift(): QueueWorker | undefined {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
slice(start?: number | undefined, end?: number | undefined): Queue {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
reverse(): Queue {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
indexOf(searchElement: QueueWorker, fromIndex?: number | undefined): number {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
lastIndexOf(searchElement: QueueWorker, fromIndex?: number | undefined): number {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
start(callback: (error?: Error | undefined, results?: any[] | null | undefined) => void): void;
|
||||
start(): Promise<{ error?: Error | undefined; results?: any[] | null | undefined }>;
|
||||
start(): void;
|
||||
start(callback?: unknown): void | Promise<{ error?: Error | undefined; results?: any[] | null | undefined }> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
stop(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
end(error?: Error | undefined): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
addEventListener<Event extends keyof EventsMap>(
|
||||
name: Event,
|
||||
callback: EventListenerOrEventListenerObject<QueueEvent<Event, EventsMap[Event]>>,
|
||||
options?: boolean | AddEventListenerOptions | undefined,
|
||||
): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
dispatchEvent<Event extends keyof EventsMap>(event: QueueEvent<Event, EventsMap[Event]>): boolean {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
removeEventListener<Event extends keyof EventsMap>(
|
||||
name: Event,
|
||||
callback: EventListenerOrEventListenerObject<QueueEvent<Event, EventsMap[Event]>>,
|
||||
options?: boolean | EventListenerOptions | undefined,
|
||||
): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
64
eslint.config.mjs
Normal file
64
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// @ts-check
|
||||
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import UnusedImportsPlugin from "eslint-plugin-unused-imports";
|
||||
import PrettierConfig from "eslint-config-prettier";
|
||||
import JestPlugin from "eslint-plugin-jest";
|
||||
import * as ImportPlugin from "eslint-plugin-import";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/", "main.js", "esbuild.config.mjs", "eslint.config.mjs", "jest.config.mjs"],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.eslintRecommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"unused-imports": UnusedImportsPlugin,
|
||||
jest: JestPlugin,
|
||||
import: ImportPlugin,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/restrict-template-expressions": "warn",
|
||||
"@typescript-eslint/no-unsafe-assignment": "warn",
|
||||
"@typescript-eslint/no-unsafe-argument": "warn",
|
||||
"@typescript-eslint/no-unsafe-member-access": "warn",
|
||||
"@typescript-eslint/no-redundant-type-constituents": "warn",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"@typescript-eslint/no-this-alias": "warn",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
|
||||
// replaced by 'unused-imports/no-unused-vars'
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"import/order": "error",
|
||||
"import/no-duplicates": "error",
|
||||
},
|
||||
},
|
||||
PrettierConfig,
|
||||
{
|
||||
files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"],
|
||||
...JestPlugin.configs["flat/recommended"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
199
jest.config.mjs
Normal file
199
jest.config.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* For a detailed explanation regarding each configuration property, visit:
|
||||
* https://jestjs.io/docs/configuration
|
||||
*/
|
||||
|
||||
const config = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls, instances, contexts and results before every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// The default configuration for fake timers
|
||||
// fakeTimers: {
|
||||
// "enableGlobally": false
|
||||
// },
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "mjs",
|
||||
// "cjs",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "json",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
"src/(.*)": "<rootDir>/src/$1",
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
preset: "ts-jest",
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state before every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state and implementation before every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "jsdom",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jest-circus/runner",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
2
main.ts
2
main.ts
|
|
@ -1,2 +1,2 @@
|
|||
import { GithubLinkPlugin } from "src/plugin";
|
||||
import { GithubLinkPlugin } from "./src/plugin";
|
||||
export default GithubLinkPlugin;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "github-link",
|
||||
"name": "GitHub Link",
|
||||
"version": "0.7.1",
|
||||
"version": "1.2.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Enrich your notes with issue and pull request content from GitHub",
|
||||
"author": "Nathonius",
|
||||
|
|
|
|||
5444
package-lock.json
generated
5444
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "obsidian-github-link",
|
||||
"version": "0.7.1",
|
||||
"version": "1.2.0",
|
||||
"description": "An Obsidian plugin enriching notes with issue and pull request content from GitHub",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint src/"
|
||||
"lint": "eslint src/",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": {
|
||||
|
|
@ -16,27 +17,36 @@
|
|||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.10.2",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@eslint/js": "^9.1.1",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||
"@typescript-eslint/parser": "^7.0.1",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.20.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"obsidian": "latest",
|
||||
"octokit": "^3.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"queue": "^7.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"tslib": "2.6.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^7.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-oauth-device": "^6.0.1",
|
||||
"@octokit/core": "^5.1.0",
|
||||
"@octokit/openapi-types": "^19.1.0",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^10.3.0",
|
||||
"@octokit/request": "^8.2.0"
|
||||
"@octokit/request": "^8.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"queue": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,6 @@ export class AuthModal extends Modal {
|
|||
}
|
||||
|
||||
private copyToClipboard() {
|
||||
window.navigator.clipboard.writeText(this.verification.user_code);
|
||||
void window.navigator.clipboard.writeText(this.verification.user_code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
|
||||
|
||||
import { Notice, requestUrl } from "obsidian";
|
||||
import Queue from "queue";
|
||||
import { PluginSettings, getCache, logger } from "../plugin";
|
||||
import { RequestError, isSuccessResponse, promiseWithResolvers } from "../util";
|
||||
import type {
|
||||
CheckRunListResponse,
|
||||
CodeResponse,
|
||||
|
|
@ -6,16 +12,13 @@ import type {
|
|||
IssueResponse,
|
||||
IssueSearchParams,
|
||||
IssueSearchResponse,
|
||||
LinkMeta,
|
||||
MaybePaginated,
|
||||
PaginationMeta,
|
||||
PullListParams,
|
||||
PullListResponse,
|
||||
PullResponse,
|
||||
} from "./response";
|
||||
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
|
||||
|
||||
import { RequestError, isSuccessResponse, promiseWithResolvers } from "src/util";
|
||||
import { Notice, requestUrl } from "obsidian";
|
||||
import { PluginSettings, getCache, logger } from "src/plugin";
|
||||
import Queue from "queue";
|
||||
import type { CacheEntry } from "./cache";
|
||||
|
||||
type RequestConfig = RequestUrlParam & { headers: Record<string, string> };
|
||||
|
|
@ -25,31 +28,88 @@ export class GitHubApi {
|
|||
private static rateLimitReset: Date | null = null;
|
||||
private static q = new Queue({ autostart: true, concurrency: 1 });
|
||||
|
||||
public async queueRequest(config: RequestUrlParam, token?: string): Promise<RequestUrlResponse> {
|
||||
public queueRequest(
|
||||
config: RequestUrlParam,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<RequestUrlResponse>> {
|
||||
// Responses we (probably) have cached will skip the queue
|
||||
if (getCache().get(config)) {
|
||||
return this.githubRequest(config, token);
|
||||
return this.githubRequest(config, token, skipCache);
|
||||
}
|
||||
|
||||
const { resolve, promise } = promiseWithResolvers<RequestUrlResponse>();
|
||||
const { resolve, reject, promise } = promiseWithResolvers<MaybePaginated<RequestUrlResponse>>();
|
||||
GitHubApi.q.push(() => {
|
||||
return this.githubRequest(config, token).then((result) => {
|
||||
resolve(result);
|
||||
});
|
||||
return this.githubRequest(config, token, skipCache)
|
||||
.then((response) => {
|
||||
resolve(response);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
public async getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
|
||||
const result = await this.queueRequest({ url: `${GitHubApi.baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
|
||||
|
||||
return result.json as IssueResponse;
|
||||
public getPaginationMeta(response: RequestUrlResponse): MaybePaginated<RequestUrlResponse> {
|
||||
const meta = this.parseLinkHeader(response.headers["link"]);
|
||||
return { meta, response };
|
||||
}
|
||||
|
||||
public async listIssuesForToken(params: IssueListParams, token: string): Promise<IssueListResponse> {
|
||||
public parseLinkHeader(link: string | undefined): PaginationMeta {
|
||||
logger.debug(`Parsing link header: ${link}`);
|
||||
const paginationMeta: PaginationMeta = {};
|
||||
if (!link) {
|
||||
return paginationMeta;
|
||||
}
|
||||
const linkRelPattern = /<(?<url>http[^\s?]+)\?(?<qp>[^\s>]*)>;\s*rel="(?<rel>[^\s"]*)"/g;
|
||||
let match: RegExpExecArray | null = null;
|
||||
do {
|
||||
match = linkRelPattern.exec(link);
|
||||
logger.debug(match);
|
||||
if (match?.groups) {
|
||||
const params = new URLSearchParams(match.groups.qp);
|
||||
const page = parseInt(params.get("page") ?? "");
|
||||
const per_page = parseInt(params.get("per_page") ?? "");
|
||||
if (isNaN(page) || isNaN(per_page)) {
|
||||
continue;
|
||||
}
|
||||
const linkMeta: LinkMeta = {
|
||||
url: match.groups.url,
|
||||
page,
|
||||
per_page,
|
||||
};
|
||||
paginationMeta[match.groups.rel as keyof PaginationMeta] = linkMeta;
|
||||
}
|
||||
} while (match);
|
||||
logger.debug(paginationMeta);
|
||||
return paginationMeta;
|
||||
}
|
||||
|
||||
public async getIssue(
|
||||
org: string,
|
||||
repo: string,
|
||||
issue: number,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<IssueResponse> {
|
||||
const { response } = await this.queueRequest(
|
||||
{ url: `${GitHubApi.baseApi}/repos/${org}/${repo}/issues/${issue}` },
|
||||
token,
|
||||
skipCache,
|
||||
);
|
||||
|
||||
return response.json as IssueResponse;
|
||||
}
|
||||
|
||||
public async listIssuesForToken(
|
||||
params: IssueListParams,
|
||||
token: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<IssueListResponse>> {
|
||||
const url = this.addParams(`${GitHubApi.baseApi}/issues`, params as Record<string, unknown>);
|
||||
const result = await this.queueRequest({ url }, token);
|
||||
return result.json as IssueListResponse;
|
||||
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
|
||||
return { meta, response: response.json as IssueListResponse };
|
||||
}
|
||||
|
||||
public async listIssuesForRepo(
|
||||
|
|
@ -57,20 +117,34 @@ export class GitHubApi {
|
|||
repo: string,
|
||||
params: IssueListParams = {},
|
||||
token?: string,
|
||||
): Promise<IssueListResponse> {
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<IssueListResponse>> {
|
||||
const url = this.addParams(`${GitHubApi.baseApi}/repos/${org}/${repo}/issues`, params as Record<string, unknown>);
|
||||
const result = await this.queueRequest({ url }, token);
|
||||
return result.json as IssueListResponse;
|
||||
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
|
||||
return { meta, response: response.json as IssueListResponse };
|
||||
}
|
||||
|
||||
public async getPullRequest(org: string, repo: string, pr: number, token?: string): Promise<PullResponse> {
|
||||
const result = await this.queueRequest(
|
||||
public async listIssuesForOrganization(org: string, params: IssueListParams, token?: string, skipCache = false) {
|
||||
const url = this.addParams(`${GitHubApi.baseApi}/orgs/${org}/issues`, params as Record<string, unknown>);
|
||||
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
|
||||
return { meta, response: response.json as IssueListResponse };
|
||||
}
|
||||
|
||||
public async getPullRequest(
|
||||
org: string,
|
||||
repo: string,
|
||||
pr: number,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<PullResponse> {
|
||||
const { response } = await this.queueRequest(
|
||||
{
|
||||
url: `${GitHubApi.baseApi}/repos/${org}/${repo}/pulls/${pr}`,
|
||||
},
|
||||
token,
|
||||
skipCache,
|
||||
);
|
||||
return result.json as PullResponse;
|
||||
return response.json as PullResponse;
|
||||
}
|
||||
|
||||
public async listPullRequestsForRepo(
|
||||
|
|
@ -78,26 +152,39 @@ export class GitHubApi {
|
|||
repo: string,
|
||||
params: PullListParams = {},
|
||||
token?: string,
|
||||
): Promise<PullListResponse> {
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<PullListResponse>> {
|
||||
const url = this.addParams(`${GitHubApi.baseApi}/repos/${org}/${repo}/pulls`, params as Record<string, unknown>);
|
||||
const result = await this.queueRequest({ url }, token);
|
||||
return result.json as PullListResponse;
|
||||
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
|
||||
return { meta, response: response.json as PullListResponse };
|
||||
}
|
||||
|
||||
public async getCode(org: string, repo: string, path: string, branch: string, token?: string): Promise<CodeResponse> {
|
||||
const result = await this.queueRequest(
|
||||
public async getCode(
|
||||
org: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
branch: string,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<CodeResponse> {
|
||||
const { response } = await this.queueRequest(
|
||||
{
|
||||
url: `${GitHubApi.baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
|
||||
},
|
||||
token,
|
||||
skipCache,
|
||||
);
|
||||
return result.json as CodeResponse;
|
||||
return response.json as CodeResponse;
|
||||
}
|
||||
|
||||
public async searchIssues(params: IssueSearchParams, token?: string): Promise<IssueSearchResponse> {
|
||||
public async searchIssues(
|
||||
params: IssueSearchParams,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<IssueSearchResponse>> {
|
||||
const url = this.addParams(`${GitHubApi.baseApi}/search/issues`, params);
|
||||
const result = await this.githubRequest({ url }, token);
|
||||
return result.json as IssueSearchResponse;
|
||||
const { meta, response } = await this.queueRequest({ url }, token, skipCache);
|
||||
return { meta, response: response.json as IssueSearchResponse };
|
||||
}
|
||||
|
||||
public async listCheckRunsForRef(
|
||||
|
|
@ -105,19 +192,21 @@ export class GitHubApi {
|
|||
repo: string,
|
||||
ref: string,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<CheckRunListResponse> {
|
||||
const result = await this.githubRequest(
|
||||
const { response } = await this.githubRequest(
|
||||
{ url: `${GitHubApi.baseApi}/${org}/${repo}/commits/${ref}/check-runs` },
|
||||
token,
|
||||
skipCache,
|
||||
);
|
||||
return result.json;
|
||||
return response.json as CheckRunListResponse;
|
||||
}
|
||||
|
||||
private async githubRequest(
|
||||
_config: RequestUrlParam,
|
||||
token?: string,
|
||||
skipCache = false,
|
||||
): Promise<RequestUrlResponse> {
|
||||
): Promise<MaybePaginated<RequestUrlResponse>> {
|
||||
if (GitHubApi.rateLimitReset !== null && GitHubApi.rateLimitReset > new Date()) {
|
||||
logger.warn(
|
||||
`GitHub rate limit exceeded. No more requests will be made until ${GitHubApi.rateLimitReset.toLocaleTimeString()}`,
|
||||
|
|
@ -129,17 +218,20 @@ export class GitHubApi {
|
|||
}
|
||||
|
||||
const config = this.initHeaders(_config, token);
|
||||
let cachedValue: CacheEntry | null = null;
|
||||
|
||||
// Check request cache first
|
||||
const cachedValue = getCache().get(config);
|
||||
if (this.cachedRequestIsRecent(cachedValue, skipCache)) {
|
||||
logger.debug(`Request was too recent. Returning cached value for: ${cachedValue?.request.url}`);
|
||||
logger.debug(cachedValue?.response);
|
||||
return cachedValue!.response;
|
||||
if (!skipCache) {
|
||||
// Check request cache first
|
||||
cachedValue = getCache().get(config);
|
||||
if (this.cachedRequestIsRecent(cachedValue)) {
|
||||
logger.debug(`Request was too recent. Returning cached value for: ${cachedValue?.request.url}`);
|
||||
logger.debug(cachedValue?.response);
|
||||
return this.getPaginationMeta(cachedValue!.response);
|
||||
}
|
||||
|
||||
this.setCacheHeaders(config, cachedValue);
|
||||
}
|
||||
|
||||
this.setCacheHeaders(config, cachedValue);
|
||||
|
||||
try {
|
||||
logger.debug(`Request: ${config.url}`);
|
||||
logger.debug(config);
|
||||
|
|
@ -150,7 +242,7 @@ export class GitHubApi {
|
|||
// Check for 304 response, return cached value
|
||||
if (cachedValue?.response && response.status === 304) {
|
||||
getCache().update(config);
|
||||
return cachedValue.response;
|
||||
return this.getPaginationMeta(cachedValue.response);
|
||||
} else if (isSuccessResponse(response.status)) {
|
||||
getCache().set(config, response);
|
||||
}
|
||||
|
|
@ -173,9 +265,10 @@ export class GitHubApi {
|
|||
logger.warn("GitHub rate limit approaching.");
|
||||
}
|
||||
|
||||
return response;
|
||||
return this.getPaginationMeta(response);
|
||||
} catch (err) {
|
||||
throw new RequestError(err as Error);
|
||||
logger.debug(err);
|
||||
return Promise.reject(new RequestError(err as Error));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,8 +311,8 @@ export class GitHubApi {
|
|||
/**
|
||||
* Returns true if we can skip calling the API due to request age
|
||||
*/
|
||||
private cachedRequestIsRecent(cachedValue: CacheEntry | null, skipCache: boolean): boolean {
|
||||
if (skipCache || !cachedValue) {
|
||||
private cachedRequestIsRecent(cachedValue: CacheEntry | null): boolean {
|
||||
if (!cachedValue) {
|
||||
return false;
|
||||
}
|
||||
// Return the cached value if it was recent enough
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
import type { RequestUrlParam, RequestUrlResponse } from "obsidian";
|
||||
import type {
|
||||
IssueListParams,
|
||||
IssueListResponse,
|
||||
IssueResponse,
|
||||
IssueSearchResponse,
|
||||
PullListParams,
|
||||
PullListResponse,
|
||||
PullResponse,
|
||||
RepoSearchResponse,
|
||||
} from "./response";
|
||||
import { logger } from "src/plugin";
|
||||
import { isSuccessResponse } from "src/util";
|
||||
import { logger } from "../plugin";
|
||||
import { isSuccessResponse, sanitizeObject } from "../util";
|
||||
|
||||
interface CacheParams {
|
||||
request: RequestUrlParam;
|
||||
|
|
@ -84,7 +74,12 @@ export class RequestCache {
|
|||
}
|
||||
|
||||
public get(request: RequestUrlParam): CacheEntry | null {
|
||||
return this.entries[this.getCacheKey(request)] ?? null;
|
||||
const entry: CacheEntry | null = this.entries[this.getCacheKey(request)] ?? null;
|
||||
// Ensure headers are defined; some old cache entries might not have them
|
||||
if (entry && !entry.response.headers) {
|
||||
entry.response.headers = {};
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public set(request: RequestUrlParam, response: RequestUrlResponse): void {
|
||||
|
|
@ -99,7 +94,11 @@ export class RequestCache {
|
|||
|
||||
// Slim down the data we store
|
||||
const _request: Partial<RequestUrlParam> = { url: request.url, body: request.body };
|
||||
const _response: Partial<RequestUrlResponse> = { json: response.json, status: response.status };
|
||||
const _response: Partial<RequestUrlResponse> = {
|
||||
json: response.json,
|
||||
status: response.status,
|
||||
headers: sanitizeObject(response.headers, { link: true }),
|
||||
};
|
||||
|
||||
const entry = new CacheEntry(
|
||||
_request as RequestUrlParam,
|
||||
|
|
@ -153,155 +152,3 @@ export class RequestCache {
|
|||
return request.url;
|
||||
}
|
||||
}
|
||||
|
||||
class OldCacheEntry<T> {
|
||||
constructor(
|
||||
public value: T,
|
||||
public created: Date = new Date(),
|
||||
public ttl: number = 20,
|
||||
) {}
|
||||
|
||||
get expired(): boolean {
|
||||
const expiry = this.created.getTime() + this.ttl * 60 * 1000;
|
||||
return new Date().getTime() > expiry;
|
||||
}
|
||||
}
|
||||
|
||||
class OldQueryCache {
|
||||
public readonly issueCache: Record<string, OldCacheEntry<IssueSearchResponse>> = {};
|
||||
public readonly repoCache: Record<string, OldCacheEntry<RepoSearchResponse>> = {};
|
||||
}
|
||||
|
||||
class OldRepoCache {
|
||||
public readonly issueCache: Record<number, OldCacheEntry<IssueResponse>> = {};
|
||||
public readonly issueListForRepoCache: Record<string, OldCacheEntry<IssueListResponse>> = {};
|
||||
public readonly pullCache: Record<string, OldCacheEntry<PullResponse>> = {};
|
||||
public readonly pullListForRepoCache: Record<string, OldCacheEntry<PullListResponse>> = {};
|
||||
}
|
||||
|
||||
class OldOrgCache {
|
||||
public readonly repos: Record<string, OldRepoCache> = {};
|
||||
public readonly issueList: Record<string, OldCacheEntry<IssueListResponse>> = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Should remove this in the one place its still used, but need an alternative solution first
|
||||
*/
|
||||
export class OldCache {
|
||||
public readonly generic: Record<string, OldCacheEntry<unknown>> = {};
|
||||
public readonly orgs: Record<string, OldOrgCache> = {};
|
||||
public readonly queries = new OldQueryCache();
|
||||
|
||||
getGeneric(url: string): unknown {
|
||||
return this.getCacheValue(this.generic[url] ?? null);
|
||||
}
|
||||
|
||||
setGeneric(url: string, value: unknown): void {
|
||||
this.generic[url] = new OldCacheEntry(value);
|
||||
}
|
||||
|
||||
getIssue(org: string, repo: string, issue: number): IssueResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.issueCache[issue] ?? null);
|
||||
}
|
||||
|
||||
setIssue(org: string, repo: string, issue: IssueResponse): void {
|
||||
const issueCache = this.getRepoCache(org, repo).issueCache;
|
||||
const existingCache = issueCache[issue.number];
|
||||
if (existingCache) {
|
||||
const now = new Date();
|
||||
existingCache.created = now;
|
||||
existingCache.value = issue;
|
||||
} else {
|
||||
issueCache[issue.number] = new OldCacheEntry<IssueResponse>(issue);
|
||||
}
|
||||
}
|
||||
|
||||
getIssueList(org: string, params: IssueListParams): IssueListResponse | null {
|
||||
const orgCache = this.getOrgCache(org);
|
||||
return this.getCacheValue(orgCache.issueList[JSON.stringify(params)] ?? null);
|
||||
}
|
||||
|
||||
setIssueList(org: string, params: IssueListParams, value: IssueListResponse): void {
|
||||
const orgCache = this.getOrgCache(org);
|
||||
orgCache.issueList[JSON.stringify(params)] = new OldCacheEntry(value);
|
||||
}
|
||||
|
||||
getIssueListForRepo(org: string, repo: string, params: IssueListParams): IssueListResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.issueListForRepoCache[JSON.stringify(params)] ?? null);
|
||||
}
|
||||
|
||||
setIssueListForRepo(org: string, repo: string, params: IssueListParams, value: IssueListResponse): void {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
repoCache.issueListForRepoCache[JSON.stringify(params)] = new OldCacheEntry(value);
|
||||
}
|
||||
|
||||
getPullRequest(org: string, repo: string, pullRequest: number): PullResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.pullCache[pullRequest] ?? null);
|
||||
}
|
||||
|
||||
getPullListForRepo(org: string, repo: string, params: PullListParams): PullListResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.pullListForRepoCache[JSON.stringify(params)] ?? null);
|
||||
}
|
||||
|
||||
setPullListForRepo(org: string, repo: string, params: PullListParams, value: PullListResponse): void {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
repoCache.pullListForRepoCache[JSON.stringify(params)] = new OldCacheEntry(value);
|
||||
}
|
||||
|
||||
setPullRequest(org: string, repo: string, pullRequest: PullResponse): void {
|
||||
const pullCache = this.getRepoCache(org, repo).pullCache;
|
||||
const existingCache = pullCache[pullRequest.number];
|
||||
if (existingCache) {
|
||||
const now = new Date();
|
||||
existingCache.created = now;
|
||||
existingCache.value = pullRequest;
|
||||
} else {
|
||||
pullCache[pullRequest.number] = new OldCacheEntry<PullResponse>(pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
getIssueQuery(query: string): IssueSearchResponse | null {
|
||||
return this.getCacheValue(this.queries.issueCache[query] ?? null);
|
||||
}
|
||||
|
||||
setIssueQuery(query: string, result: IssueSearchResponse): void {
|
||||
this.queries.issueCache[query] = new OldCacheEntry<IssueSearchResponse>(result);
|
||||
}
|
||||
|
||||
getRepoQuery(query: string): RepoSearchResponse | null {
|
||||
return this.getCacheValue(this.queries.repoCache[query] ?? null);
|
||||
}
|
||||
|
||||
setRepoQuery(query: string, result: RepoSearchResponse): void {
|
||||
this.queries.repoCache[query] = new OldCacheEntry<RepoSearchResponse>(result);
|
||||
}
|
||||
|
||||
private getOrgCache(org: string): OldOrgCache {
|
||||
let orgCache = this.orgs[org];
|
||||
if (!orgCache) {
|
||||
orgCache = this.orgs[org] = new OldOrgCache();
|
||||
}
|
||||
return orgCache;
|
||||
}
|
||||
|
||||
private getRepoCache(org: string, repo: string) {
|
||||
const orgCache = this.getOrgCache(org);
|
||||
let repoCache = orgCache.repos[repo];
|
||||
if (!repoCache) {
|
||||
repoCache = orgCache.repos[repo] = new OldRepoCache();
|
||||
}
|
||||
return repoCache;
|
||||
}
|
||||
|
||||
private getCacheValue<T>(cacheEntry: OldCacheEntry<T> | null): T | null {
|
||||
if (!cacheEntry || cacheEntry.expired) {
|
||||
return null;
|
||||
} else {
|
||||
return cacheEntry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import { RequestError, mapObject } from "../util";
|
||||
|
||||
import type { GithubAccount } from "../settings";
|
||||
import { PluginSettings } from "../plugin";
|
||||
import { issueListSortFromQuery, pullListSortFromQuery, searchSortFromQuery } from "../query/sort";
|
||||
import type { QueryParams } from "../query/types";
|
||||
import { GitHubApi } from "./api";
|
||||
import type {
|
||||
CheckRunListResponse,
|
||||
IssueListParams,
|
||||
|
|
@ -6,20 +13,15 @@ import type {
|
|||
IssueSearchParams,
|
||||
IssueSearchResponse,
|
||||
IssueTimelineResponse,
|
||||
MaybePaginated,
|
||||
PullListParams,
|
||||
PullListResponse,
|
||||
PullResponse,
|
||||
TimelineCrossReferencedEvent,
|
||||
} from "./response";
|
||||
import type { RemoveIndexSignature } from "src/util";
|
||||
import { RequestError, sanitizeObject } from "src/util";
|
||||
import { GitHubApi } from "./api";
|
||||
|
||||
import { OldCache } from "./cache";
|
||||
import type { GithubAccount } from "src/settings";
|
||||
import { PluginSettings } from "src/plugin";
|
||||
// TODO: Refactor this whole file into a class for better use in dataview queries, etc
|
||||
|
||||
const cache = new OldCache();
|
||||
const tokenMatchRegex = /repo:(.+)\//;
|
||||
const api = new GitHubApi();
|
||||
|
||||
|
|
@ -45,133 +47,136 @@ function getToken(org?: string, query?: string): string | undefined {
|
|||
return account?.token;
|
||||
}
|
||||
|
||||
export function getIssue(org: string, repo: string, issue: number): Promise<IssueResponse> {
|
||||
return api.getIssue(org, repo, issue, getToken(org));
|
||||
export function getIssue(org: string, repo: string, issue: number, skipCache = false): Promise<IssueResponse> {
|
||||
return api.getIssue(org, repo, issue, getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export function getMyIssues(params: IssueListParams, org?: string, skipCache = false): Promise<IssueListResponse> {
|
||||
export function getMyIssues(
|
||||
params: QueryParams,
|
||||
org?: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<IssueListResponse>> {
|
||||
const account = getAccount(org);
|
||||
if (!account?.token) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const _params = sanitizeObject(params, {
|
||||
assignee: false,
|
||||
creator: false,
|
||||
direction: true,
|
||||
labels: true,
|
||||
mentioned: false,
|
||||
milestone: false,
|
||||
page: true,
|
||||
per_page: true,
|
||||
since: true,
|
||||
sort: true,
|
||||
state: true,
|
||||
filter: true,
|
||||
org: false,
|
||||
repo: false,
|
||||
});
|
||||
|
||||
setPageSize(_params);
|
||||
|
||||
if (Array.isArray(_params.labels)) {
|
||||
_params.labels = _params.labels.join(",");
|
||||
return Promise.resolve({ meta: {}, response: [] });
|
||||
}
|
||||
|
||||
return api.listIssuesForToken(_params, account.token);
|
||||
return api.listIssuesForToken(toIssueListParams(params), account.token, skipCache);
|
||||
}
|
||||
|
||||
export function getIssuesForRepo(
|
||||
params: IssueListParams,
|
||||
params: QueryParams,
|
||||
org: string,
|
||||
repo: string,
|
||||
skipCache = false,
|
||||
): Promise<IssueListResponse> {
|
||||
const _params = sanitizeObject(params, {
|
||||
assignee: true,
|
||||
creator: true,
|
||||
direction: true,
|
||||
labels: true,
|
||||
mentioned: true,
|
||||
milestone: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
since: true,
|
||||
sort: true,
|
||||
state: true,
|
||||
org: false,
|
||||
repo: false,
|
||||
filter: false,
|
||||
});
|
||||
|
||||
setPageSize(_params);
|
||||
|
||||
if (Array.isArray(_params.labels)) {
|
||||
_params.labels = _params.labels.join(",");
|
||||
}
|
||||
|
||||
return api.listIssuesForRepo(org, repo, _params, getToken(org));
|
||||
): Promise<MaybePaginated<IssueListResponse>> {
|
||||
return api.listIssuesForRepo(org, repo, toIssueListParams(params), getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export function getPullRequest(org: string, repo: string, pullRequest: number): Promise<PullResponse> {
|
||||
return api.getPullRequest(org, repo, pullRequest, getToken(org));
|
||||
export function getIssuesForOrganization(
|
||||
params: QueryParams,
|
||||
org: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<IssueListResponse>> {
|
||||
const listParams = mapObject<QueryParams, IssueListParams>(
|
||||
params,
|
||||
{
|
||||
assignee: true,
|
||||
creator: true,
|
||||
direction: true,
|
||||
labels: (params) => {
|
||||
if (Array.isArray(params.labels)) {
|
||||
return params.labels.join(",");
|
||||
}
|
||||
return params.labels;
|
||||
},
|
||||
mentioned: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
since: true,
|
||||
sort: (params) => issueListSortFromQuery(params),
|
||||
state: true,
|
||||
filter: true,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setPageSize(listParams);
|
||||
|
||||
return api.listIssuesForOrganization(org, listParams, getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export function getPullRequestsForRepo(params: PullListParams, org: string, repo: string): Promise<PullListResponse> {
|
||||
const _params = sanitizeObject(params, {
|
||||
org: false,
|
||||
repo: false,
|
||||
base: true,
|
||||
direction: true,
|
||||
head: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
sort: true,
|
||||
state: true,
|
||||
});
|
||||
|
||||
setPageSize(_params);
|
||||
return api.listPullRequestsForRepo(org, repo, _params, getToken(org));
|
||||
export function getPullRequest(
|
||||
org: string,
|
||||
repo: string,
|
||||
pullRequest: number,
|
||||
skipCache = false,
|
||||
): Promise<PullResponse> {
|
||||
return api.getPullRequest(org, repo, pullRequest, getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export function listCheckRunsForRef(org: string, repo: string, ref: string): Promise<CheckRunListResponse> {
|
||||
return api.listCheckRunsForRef(org, repo, ref, getToken(org));
|
||||
export function getPullRequestsForRepo(
|
||||
params: QueryParams,
|
||||
org: string,
|
||||
repo: string,
|
||||
skipCache = false,
|
||||
): Promise<MaybePaginated<PullListResponse>> {
|
||||
const listParams = mapObject<QueryParams, PullListParams>(
|
||||
params,
|
||||
{
|
||||
direction: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
sort: (params) => pullListSortFromQuery(params),
|
||||
state: true,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setPageSize(listParams);
|
||||
return api.listPullRequestsForRepo(org, repo, listParams, getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export function listCheckRunsForRef(
|
||||
org: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
skipCache = false,
|
||||
): Promise<CheckRunListResponse> {
|
||||
return api.listCheckRunsForRef(org, repo, ref, getToken(org), skipCache);
|
||||
}
|
||||
|
||||
export async function searchIssues(
|
||||
params: RemoveIndexSignature<IssueSearchParams>,
|
||||
params: QueryParams,
|
||||
query: string,
|
||||
org?: string,
|
||||
skipCache = false,
|
||||
): Promise<IssueSearchResponse> {
|
||||
const _params = sanitizeObject(params, {
|
||||
q: false,
|
||||
baseUrl: false,
|
||||
headers: false,
|
||||
mediaType: false,
|
||||
order: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
request: false,
|
||||
sort: true,
|
||||
});
|
||||
): Promise<MaybePaginated<IssueSearchResponse>> {
|
||||
const searchParams = mapObject<QueryParams, IssueSearchParams>(
|
||||
params,
|
||||
{
|
||||
q: () => query,
|
||||
sort: (params) => searchSortFromQuery(params),
|
||||
order: (params) => params.order,
|
||||
page: (params) => params.page,
|
||||
per_page: (params) => params.per_page,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
setPageSize(_params);
|
||||
_params.q = query;
|
||||
|
||||
const cachedResponse = cache.getIssueQuery(query);
|
||||
if (cachedResponse && !skipCache) {
|
||||
return Promise.resolve(cachedResponse);
|
||||
}
|
||||
|
||||
const response = await api.searchIssues(_params, getToken(org, query));
|
||||
cache.setIssueQuery(query, response);
|
||||
return response;
|
||||
setPageSize(searchParams);
|
||||
return api.searchIssues(searchParams, getToken(org, query), skipCache);
|
||||
}
|
||||
|
||||
// TODO: This is in the wrong place and should be at the API level to be properly cached
|
||||
export async function getPRForIssue(timelineUrl: string, org?: string): Promise<string | null> {
|
||||
let response: IssueTimelineResponse | null = null;
|
||||
let result: IssueTimelineResponse | null = null;
|
||||
try {
|
||||
response = (await api.queueRequest({ url: timelineUrl }, getToken(org))).json;
|
||||
const { response } = await api.queueRequest({ url: timelineUrl }, getToken(org));
|
||||
result = response.json as IssueTimelineResponse;
|
||||
} catch (err) {
|
||||
// 404 means there's no timeline for this, we can ignore the error
|
||||
if (err instanceof RequestError && err.status === 404) {
|
||||
|
|
@ -180,12 +185,12 @@ export async function getPRForIssue(timelineUrl: string, org?: string): Promise<
|
|||
throw err;
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Figure out a better/more reliable way to do this.
|
||||
const crossRefEvent = response.find((_evt) => {
|
||||
const crossRefEvent = result.find((_evt) => {
|
||||
const evt = _evt as Partial<TimelineCrossReferencedEvent>;
|
||||
return evt.event === "cross-referenced" && evt.source?.issue?.pull_request?.html_url;
|
||||
}) as TimelineCrossReferencedEvent | undefined;
|
||||
|
|
@ -195,3 +200,30 @@ export async function getPRForIssue(timelineUrl: string, org?: string): Promise<
|
|||
function setPageSize(params: { per_page?: number }): void {
|
||||
params.per_page = params.per_page ?? PluginSettings.defaultPageSize;
|
||||
}
|
||||
|
||||
function toIssueListParams(queryParams: QueryParams): IssueListParams {
|
||||
const listParams = mapObject<QueryParams, IssueListParams>(
|
||||
queryParams,
|
||||
{
|
||||
assignee: true,
|
||||
creator: true,
|
||||
direction: true,
|
||||
labels: (params) => {
|
||||
if (Array.isArray(params.labels)) {
|
||||
return params.labels.join(",");
|
||||
}
|
||||
return params.labels;
|
||||
},
|
||||
mentioned: true,
|
||||
page: true,
|
||||
per_page: true,
|
||||
since: true,
|
||||
sort: (params) => issueListSortFromQuery(params),
|
||||
state: true,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
setPageSize(listParams);
|
||||
return listParams;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,24 @@ export interface PaginationParams {
|
|||
page?: number;
|
||||
}
|
||||
|
||||
export interface LinkMeta {
|
||||
url: string;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
first?: LinkMeta;
|
||||
prev?: LinkMeta;
|
||||
next?: LinkMeta;
|
||||
last?: LinkMeta;
|
||||
}
|
||||
|
||||
export interface MaybePaginated<T> {
|
||||
meta: PaginationMeta;
|
||||
response: T;
|
||||
}
|
||||
|
||||
// Response Types
|
||||
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
|
||||
export type IssueListResponse = RestEndpointMethodTypes["issues"]["list"]["response"]["data"];
|
||||
|
|
@ -25,6 +43,8 @@ export type TimelineCrossReferencedEvent = OpenAPI.components["schemas"]["timeli
|
|||
export type IssueTimelineResponse = RestEndpointMethodTypes["issues"]["listEventsForTimeline"]["response"]["data"];
|
||||
export type CheckRunListResponse = RestEndpointMethodTypes["checks"]["listForRef"]["response"]["data"];
|
||||
|
||||
export type UserResponse = IssueListResponse[number]["user"];
|
||||
|
||||
// Param Types
|
||||
export type RepoSearchParams = RestEndpointMethodTypes["search"]["repos"]["parameters"];
|
||||
export type IssueListParams = PaginationParams & {
|
||||
|
|
@ -52,7 +72,9 @@ export type PullListParams = PaginationParams & {
|
|||
};
|
||||
export type IssueSearchParams = RestEndpointMethodTypes["search"]["issuesAndPullRequests"]["parameters"];
|
||||
|
||||
export function getSearchResultIssueStatus(issue: IssueSearchResponse["items"][number]): IssueStatus {
|
||||
export function getSearchResultIssueStatus(
|
||||
issue: Pick<IssueSearchResponse["items"][number], "state" | "state_reason" | "pull_request" | "closed_at">,
|
||||
): IssueStatus {
|
||||
if (issue.pull_request?.merged_at || issue.state_reason === "completed") {
|
||||
return IssueStatus.Done;
|
||||
} else if (issue.closed_at || issue.state === "closed") {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { logger } from "../plugin";
|
||||
|
||||
export interface ParsedUrl {
|
||||
url: string;
|
||||
host: string;
|
||||
|
|
@ -8,6 +10,8 @@ export interface ParsedUrl {
|
|||
code?: {
|
||||
branch?: string;
|
||||
path?: string;
|
||||
filename?: string;
|
||||
line?: string;
|
||||
};
|
||||
commit?: string;
|
||||
}
|
||||
|
|
@ -18,7 +22,7 @@ export function repoAPIToBrowserUrl(urlString: string): string {
|
|||
return urlString.replace(apiRegex, "https://github.com/");
|
||||
}
|
||||
|
||||
export function parseUrl(urlString: string): ParsedUrl {
|
||||
export function parseUrl(urlString: string): ParsedUrl | null {
|
||||
// Some potential URLs:
|
||||
// https://github.com/nathonius/alloy-theme
|
||||
// https://github.com/nathonius/obsidian-trello/issues/45
|
||||
|
|
@ -28,7 +32,14 @@ export function parseUrl(urlString: string): ParsedUrl {
|
|||
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts#L44-L58
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1#diff-8fa4b52909f895e8cda060d2035234e0a42ca2c7d3f8f8de1b35a056537bf199R35
|
||||
const url = new URL(urlString);
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch (err) {
|
||||
logger.error(`Attempted and failed to parse invalid URL: ${urlString}`);
|
||||
return null;
|
||||
}
|
||||
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
|
||||
|
||||
const urlParts = url.pathname.split("/");
|
||||
|
|
@ -51,6 +62,8 @@ export function parseUrl(urlString: string): ParsedUrl {
|
|||
if (urlParts[5]) {
|
||||
const pathParts = urlParts.slice(5);
|
||||
parsedUrl.code.path = pathParts.join("/");
|
||||
parsedUrl.code.filename = pathParts.last();
|
||||
parsedUrl.code.line = url.hash.slice(1);
|
||||
}
|
||||
break;
|
||||
case "commit":
|
||||
|
|
|
|||
39
src/github/urls.ts
Normal file
39
src/github/urls.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* These are paths that begin with `https://github.com` but should not
|
||||
* be processed by the plugin.
|
||||
*/
|
||||
const knownPaths = [
|
||||
"about",
|
||||
"account",
|
||||
"codespaces",
|
||||
"dashboard",
|
||||
"discussions",
|
||||
"explore",
|
||||
"gist",
|
||||
"github-copilot",
|
||||
"issues",
|
||||
"logout",
|
||||
"marketplace",
|
||||
"mine",
|
||||
"new",
|
||||
"organizations",
|
||||
"projects",
|
||||
"pulls",
|
||||
"search",
|
||||
"security",
|
||||
"settings",
|
||||
"sponsors",
|
||||
];
|
||||
|
||||
export function isAllowedPath(link: string): boolean {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
const basePath = url.pathname.split("/")[1];
|
||||
if (knownPaths.includes(basePath)) {
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -56,6 +56,10 @@ export function setIssueIcon(icon: HTMLElement, status: IssueStatus): void {
|
|||
icon.dataset.status = status;
|
||||
}
|
||||
|
||||
export function setFileIcon(icon: HTMLElement): void {
|
||||
setIcon(icon, "file");
|
||||
}
|
||||
|
||||
export function setPRMergeableIcon(icon: HTMLElement, mergeable: boolean): void {
|
||||
if (PluginSettings.tagTooltips) {
|
||||
icon.setAttribute("aria-label", PRMergeableText[`${mergeable}`]);
|
||||
|
|
|
|||
46
src/inline/inline.spec.ts
Normal file
46
src/inline/inline.spec.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { test, describe, expect, beforeAll } from "@jest/globals";
|
||||
import { elementTestSetup } from "../../test/element";
|
||||
import { createTag } from "./inline";
|
||||
|
||||
const selectors = {
|
||||
org: ".github-link-inline-org",
|
||||
repo: ".github-link-inline-repo",
|
||||
issueTitle: ".github-link-inline-issue-title",
|
||||
prTitle: ".github-link-inline-pr-title",
|
||||
file: ".github-link-inline-file",
|
||||
};
|
||||
|
||||
describe("createTag", () => {
|
||||
beforeAll(() => {
|
||||
elementTestSetup();
|
||||
});
|
||||
test.each([
|
||||
{ url: "https://github.com/nathonius", user: "nathonius" },
|
||||
{ url: "https://github.com/nathonius/", user: "nathonius" },
|
||||
{ url: "https://github.com/sam.lake", user: "sam.lake" },
|
||||
{ url: "https://github.com/abraham_lincoln", user: "abraham_lincoln" },
|
||||
])("user only $user", ({ url, user }) => {
|
||||
const tag = createTag(url);
|
||||
expect(tag).toBeTruthy();
|
||||
const org = tag?.querySelector(selectors.org) as HTMLSpanElement;
|
||||
expect(org.innerText).toEqual(user);
|
||||
[selectors.repo, selectors.issueTitle, selectors.prTitle, selectors.file].forEach((s) => {
|
||||
expect(tag?.querySelector(s)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ url: "https://github.com/nathonius/obsidian-github-link", user: "nathonius", repo: "obsidian-github-link" },
|
||||
{ url: "https://github.com/nathonius/obsidian.github.link", user: "nathonius", repo: "obsidian.github.link" },
|
||||
])("repo $repo", ({ url, user, repo }) => {
|
||||
const tag = createTag(url);
|
||||
expect(tag).toBeTruthy();
|
||||
const org = tag?.querySelector(selectors.org) as HTMLSpanElement;
|
||||
const repoEl = tag?.querySelector(selectors.repo) as HTMLSpanElement;
|
||||
expect(org.innerText).toEqual(user);
|
||||
expect(repoEl.innerText).toEqual(repo);
|
||||
[selectors.issueTitle, selectors.prTitle, selectors.file].forEach((s) => {
|
||||
expect(tag?.querySelector(s)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +1,27 @@
|
|||
import type { PullResponse } from "src/github/response";
|
||||
import { IssueStatus, getIssueStatus, getPRStatus } from "src/github/response";
|
||||
import { getIssue, getPullRequest } from "../github/github";
|
||||
|
||||
import type { ParsedUrl } from "../github/url-parse";
|
||||
import { parseUrl } from "../github/url-parse";
|
||||
import { setIcon } from "obsidian";
|
||||
import { setIssueIcon, setPRIcon, setPRMergeableIcon } from "src/icon";
|
||||
import { PluginSettings } from "src/plugin";
|
||||
import { IssueStatus, getIssueStatus, getPRStatus } from "../github/response";
|
||||
import { setFileIcon, setIssueIcon, setPRIcon, setPRMergeableIcon } from "../icon";
|
||||
|
||||
import { PluginSettings } from "../plugin";
|
||||
import type { PullResponse } from "../github/response";
|
||||
import { RequestError } from "../util";
|
||||
import { parseUrl } from "../github/url-parse";
|
||||
import type { ParsedUrl } from "../github/url-parse";
|
||||
import { getIssue, getPullRequest } from "../github/github";
|
||||
import { isAllowedPath } from "../github/urls";
|
||||
|
||||
interface TagConfig {
|
||||
icon: HTMLSpanElement;
|
||||
sections: HTMLElement[];
|
||||
}
|
||||
|
||||
export function createTag(href: string): HTMLAnchorElement {
|
||||
export function createTag(href: string): HTMLAnchorElement | null {
|
||||
const parsedUrl = parseUrl(href);
|
||||
const container = createEl("a", { cls: "github-link-inline", href });
|
||||
if (!parsedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = createEl("a", { cls: "github-link-inline", href, attr: { target: "_blank" } });
|
||||
const config: TagConfig = {
|
||||
icon: createSpan({ cls: ["github-link-status-icon", "github-link-inline-icon"] }),
|
||||
sections: [],
|
||||
|
|
@ -25,8 +31,8 @@ export function createTag(href: string): HTMLAnchorElement {
|
|||
createOrgSection(config, parsedUrl);
|
||||
createRepoSection(config, parsedUrl);
|
||||
|
||||
// Add issue OR pr
|
||||
if (parsedUrl.issue !== undefined || parsedUrl.pr !== undefined) {
|
||||
// Add issue OR pr OR file
|
||||
if (parsedUrl.issue !== undefined || parsedUrl.pr !== undefined || parsedUrl.code !== undefined) {
|
||||
// Remove org
|
||||
const orgIndex = config.sections.findIndex((section) => section.classList.contains("github-link-inline-org"));
|
||||
if (orgIndex !== -1) {
|
||||
|
|
@ -34,9 +40,11 @@ export function createTag(href: string): HTMLAnchorElement {
|
|||
}
|
||||
|
||||
if (parsedUrl.issue !== undefined) {
|
||||
createIssueSection(config, parsedUrl);
|
||||
createIssueSection(config, parsedUrl, container);
|
||||
} else if (parsedUrl.pr !== undefined) {
|
||||
createPullRequestSection(config, parsedUrl, container);
|
||||
} else if (parsedUrl.code !== undefined) {
|
||||
createFileSection(config, parsedUrl, container);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,28 +92,33 @@ function createRepoSection(config: TagConfig, parsedUrl: ParsedUrl) {
|
|||
}
|
||||
}
|
||||
|
||||
function createIssueSection(config: TagConfig, parsedUrl: ParsedUrl) {
|
||||
function createIssueSection(config: TagConfig, parsedUrl: ParsedUrl, container: HTMLAnchorElement) {
|
||||
if (parsedUrl.issue === undefined) {
|
||||
return;
|
||||
}
|
||||
setIssueIcon(config.icon, IssueStatus.Open);
|
||||
const issueContainer = createSpan({
|
||||
cls: "github-link-inline-issue-title",
|
||||
text: `${parsedUrl.issue}`,
|
||||
cls: "github-link-inline-issue-title-section",
|
||||
});
|
||||
issueContainer.createSpan({ text: `#${parsedUrl.issue}`, cls: "github-link-inline-issue-number" });
|
||||
config.sections.push(issueContainer);
|
||||
if (parsedUrl.org && parsedUrl.repo) {
|
||||
getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue).then((issue) => {
|
||||
if (issue.title) {
|
||||
const status = getIssueStatus(issue);
|
||||
setIssueIcon(config.icon, status);
|
||||
issueContainer.setText(issue.title);
|
||||
}
|
||||
});
|
||||
getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue)
|
||||
.then((issue) => {
|
||||
if (issue.title) {
|
||||
const title = createSpan({ text: issue.title, cls: "github-link-inline-issue-title" });
|
||||
issueContainer.prepend(title);
|
||||
const status = getIssueStatus(issue);
|
||||
setIssueIcon(config.icon, status);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
createErrorSection(config, container, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createPullRequestSection(config: TagConfig, parsedUrl: ParsedUrl, tag: HTMLElement) {
|
||||
function createPullRequestSection(config: TagConfig, parsedUrl: ParsedUrl, container: HTMLAnchorElement) {
|
||||
if (parsedUrl.pr === undefined) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -116,36 +129,80 @@ function createPullRequestSection(config: TagConfig, parsedUrl: ParsedUrl, tag:
|
|||
});
|
||||
config.sections.push(prContainer);
|
||||
if (parsedUrl.org && parsedUrl.repo) {
|
||||
getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr).then((pr) => {
|
||||
if (pr.title) {
|
||||
const status = getPRStatus(pr);
|
||||
setPRIcon(config.icon, status);
|
||||
prContainer.setText(pr.title);
|
||||
}
|
||||
createPullRequestMergeableSection(config, pr, tag);
|
||||
});
|
||||
getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr)
|
||||
.then((pr) => {
|
||||
if (pr.title) {
|
||||
const status = getPRStatus(pr);
|
||||
setPRIcon(config.icon, status);
|
||||
prContainer.setText(pr.title);
|
||||
}
|
||||
createPullRequestMergeableSection(config, pr, container);
|
||||
})
|
||||
.catch((err) => {
|
||||
createErrorSection(config, container, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createFileSection(config: TagConfig, parsedUrl: ParsedUrl, _container: HTMLAnchorElement) {
|
||||
if (parsedUrl.code === undefined) {
|
||||
return;
|
||||
}
|
||||
const fileContainer = createSpan({ cls: "github-link-inline-file" });
|
||||
setFileIcon(config.icon);
|
||||
config.sections.push(fileContainer);
|
||||
if (parsedUrl.code.filename) {
|
||||
fileContainer.setText(parsedUrl.code.filename);
|
||||
} else if (parsedUrl.code.path) {
|
||||
fileContainer.setText(parsedUrl.code.path);
|
||||
}
|
||||
if (PluginSettings.tagShowFileLineNumber && parsedUrl.code.line) {
|
||||
fileContainer.appendChild(createSpan({ cls: "github-link-inline-file-line-number", text: parsedUrl.code.line }));
|
||||
}
|
||||
if (PluginSettings.tagShowFileBranchName && parsedUrl.code.branch) {
|
||||
fileContainer.appendChild(
|
||||
createSpan({ cls: "github-link-inline-file-branch", text: `(${parsedUrl.code.branch})` }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that this function is called AFTER the tag has been built, so it adds itself to the dom.
|
||||
*/
|
||||
function createPullRequestMergeableSection(config: TagConfig, pullRequest: PullResponse, tag: HTMLElement): void {
|
||||
function createPullRequestMergeableSection(config: TagConfig, pullRequest: PullResponse, container: HTMLElement): void {
|
||||
if (!PluginSettings.tagShowPRMergeable || pullRequest.mergeable === null) {
|
||||
return;
|
||||
}
|
||||
const mergeableIcon = createSpan({ cls: ["github-link-inline-pr-mergeable-icon", "github-link-inline-icon"] });
|
||||
setPRMergeableIcon(mergeableIcon, pullRequest.mergeable);
|
||||
config.sections.push(mergeableIcon);
|
||||
tag.appendChild(createTagSection(mergeableIcon));
|
||||
container.appendChild(createTagSection(mergeableIcon));
|
||||
}
|
||||
|
||||
export async function InlineRenderer(el: HTMLElement) {
|
||||
const githubLinks = el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`);
|
||||
function createErrorSection(config: TagConfig, container: HTMLAnchorElement, error: unknown) {
|
||||
const errorIcon = createSpan({
|
||||
cls: ["github-link-inline-error-icon", "github-link-inline-icon"],
|
||||
});
|
||||
setIcon(errorIcon, "lucide-alert-triangle");
|
||||
if (error instanceof RequestError) {
|
||||
errorIcon.ariaLabel = error.message;
|
||||
}
|
||||
config.sections.push(errorIcon);
|
||||
container.appendChild(createTagSection(errorIcon));
|
||||
}
|
||||
|
||||
export function InlineRenderer(el: HTMLElement) {
|
||||
let githubLinks = Array.from(el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`));
|
||||
|
||||
// Filter out some special URLs from github
|
||||
githubLinks = githubLinks.filter((l) => isAllowedPath(l.href));
|
||||
|
||||
for (const anchor of Array.from(githubLinks)) {
|
||||
if (anchor.href === anchor.innerText) {
|
||||
const container = createTag(anchor.href);
|
||||
anchor.replaceWith(container);
|
||||
if (container) {
|
||||
anchor.replaceWith(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
src/inline/view-plugin.spec.ts
Normal file
103
src/inline/view-plugin.spec.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { expect, test, describe } from "@jest/globals";
|
||||
import { matchRegexp } from "./view-plugin";
|
||||
|
||||
// Some potential URLs:
|
||||
const GITHUB_URLS = [
|
||||
// USER/ORG
|
||||
"https://github.com/nathonius",
|
||||
// REPO
|
||||
"https://github.com/nathonius/obsidian-github-link",
|
||||
"https://github.com/nathonius/obsidian-github-link/tree/main",
|
||||
"https://github.com/joshleaves/obsidian-github-link/tree/fix/156-linking-to-file",
|
||||
"https://github.com/nathonius/obsidian-github-link/blob/main/src/github/url-parse.ts",
|
||||
"https://github.com/nathonius/obsidian-github-link/blob/main/src/inline/view-plugin.ts#L21",
|
||||
"https://github.com/nathonius/obsidian-github-link/blob/main/src/inline/view-plugin.ts#L13-32",
|
||||
"https://github.com/academicpages/academicpages.github.io",
|
||||
// ISSUES
|
||||
"https://github.com/nathonius/obsidian-github-link/issues",
|
||||
"https://github.com/nathonius/obsidian-github-link/issues/156",
|
||||
// PULLS
|
||||
"https://github.com/nathonius/obsidian-github-link/pulls",
|
||||
"https://github.com/nathonius/obsidian-github-link/pull/157",
|
||||
"https://github.com/nathonius/obsidian-github-link/pull/157/commits",
|
||||
"https://github.com/nathonius/obsidian-github-link/pull/157/commits/42d35e4c7070d2ec9f3bacf7f4a0561d9d7346bb",
|
||||
"https://github.com/nathonius/obsidian-github-link/pull/157/files",
|
||||
// MORE EDGE CASES
|
||||
"https://github.com/kwsch/pk3DS/blob/e40d3ce5548d75821f31785dc88cd465610530a6/pk3DS.Core/CTR/Images/BCLIM.cs",
|
||||
"https://github.com/kwsch/png2bclim/blob/master/png2bclim/BCLIM.cs",
|
||||
"https://github.com/kwsch/png2bclim",
|
||||
"https://github.com/ihaveamac/3DS-rom-tools/wiki/Extract-a-game-or-application-in-.3ds-or-.cci-format",
|
||||
];
|
||||
|
||||
describe("url regex match", () => {
|
||||
test.each(GITHUB_URLS)("matches GitHub URLs on their own", (url) => {
|
||||
const text = `${url}`; // (...)
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("does not match URLs inside markdown links", (url) => {
|
||||
const text = `[A link to GitHub](${url})`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs ending a sentence", (url) => {
|
||||
const text = `That's the end of the line for ${url}.`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs followed by punctuation", (url) => {
|
||||
const text = `First ${url}, then some text.`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs ending a sentence with an interrogative", (url) => {
|
||||
const text = `You heard about ${url}?`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs followed by multiple punctuation", (url) => {
|
||||
const text = `A test for ${url}...`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs ending a sentence that segues to another", (url) => {
|
||||
const text = `That's the end of the line for ${url}. But not for this variable!`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs in text with multiple lines", (url) => {
|
||||
const text = `Some text
|
||||
${url}
|
||||
more text.`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs in text followed by another text", (url) => {
|
||||
const text = `${url} is a cool plugin`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
|
||||
test.each(GITHUB_URLS)("matches URLs quoted", (url) => {
|
||||
const text = `Obama chuckled: you mean "${url}" ?`;
|
||||
const match = text.match(matchRegexp);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![0]).toBe(url);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,90 +1,151 @@
|
|||
import { editorLivePreviewField } from "obsidian";
|
||||
import type { DecorationSet, PluginValue, EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Decoration, MatchDecorator, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||
import type { DecorationSet, EditorView, PluginSpec, PluginValue, ViewUpdate } from "@codemirror/view";
|
||||
|
||||
import type { GithubLinkPlugin } from "../plugin";
|
||||
import { isAllowedPath } from "../github/urls";
|
||||
import { createTag } from "./inline";
|
||||
|
||||
interface DecoSpec {
|
||||
widget?: InlineTagWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeMirror widget that replaces the text via Decoration
|
||||
* This should be kept as simple as possible; logic on whether
|
||||
* or not to render the widget is part of the view plugin
|
||||
*/
|
||||
class InlineTagWidget extends WidgetType {
|
||||
public error = false;
|
||||
private container: HTMLElement = createSpan();
|
||||
constructor(
|
||||
public readonly href: string,
|
||||
dispatch: () => void,
|
||||
) {
|
||||
public readonly href: string;
|
||||
constructor(public readonly match: RegExpExecArray) {
|
||||
super();
|
||||
const tag = createTag(href);
|
||||
this.container.appendChild(tag);
|
||||
this.href = match[0];
|
||||
}
|
||||
|
||||
eq(widget: WidgetType): boolean {
|
||||
return (widget as InlineTagWidget).href === this.href;
|
||||
eq(other: WidgetType): boolean {
|
||||
return other instanceof InlineTagWidget && other.href === this.href;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
return this.container;
|
||||
const container = createSpan();
|
||||
const tag = createTag(this.href);
|
||||
if (tag) {
|
||||
container.appendChild(tag);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
export const matchRegexp = /(?<!]\()https:\/\/github\.com\/[^\s]+(?<!['".?,:;]+)/g;
|
||||
|
||||
export function createInlineViewPlugin(_plugin: GithubLinkPlugin) {
|
||||
class InlineViewPluginValue implements PluginValue {
|
||||
public readonly view: EditorView;
|
||||
private readonly match = new MatchDecorator({
|
||||
regexp: /(https:\/\/)?github\.com\S+/g,
|
||||
decorate: (add, from, to, match, view) => {
|
||||
const shouldRender = this.shouldRender(view, from, to, match);
|
||||
if (shouldRender) {
|
||||
add(
|
||||
from,
|
||||
to,
|
||||
Decoration.replace({
|
||||
widget: new InlineTagWidget(match[0], view.dispatch),
|
||||
}),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* State for parsing content of a file for code blocks
|
||||
*/
|
||||
public lastMatchEnd: number = 0;
|
||||
public inCodeblock = false;
|
||||
|
||||
public inlineTags: DecorationSet = Decoration.none;
|
||||
|
||||
private readonly matcher = new MatchDecorator({
|
||||
regexp: matchRegexp,
|
||||
decorate: (add, from, to, match, _view) => {
|
||||
add(
|
||||
from,
|
||||
to,
|
||||
Decoration.replace({
|
||||
widget: new InlineTagWidget(match),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
decorations: DecorationSet = Decoration.none;
|
||||
constructor(view: EditorView) {
|
||||
this.view = view;
|
||||
this.updateDecorations(view);
|
||||
|
||||
constructor(private readonly view: EditorView) {
|
||||
this.inlineTags = this.matcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
this.updateDecorations(update.view, update);
|
||||
this.inlineTags = this.matcher.updateDeco(update, this.inlineTags);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.decorations = Decoration.none;
|
||||
this.inlineTags = Decoration.none;
|
||||
}
|
||||
|
||||
updateDecorations(view: EditorView, update?: ViewUpdate) {
|
||||
if (!update || this.decorations.size === 0) {
|
||||
this.decorations = this.match.createDeco(view);
|
||||
} else {
|
||||
this.decorations = this.match.updateDeco(update, this.decorations);
|
||||
isLivePreview(): boolean {
|
||||
return this.view.state.field(editorLivePreviewField);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the decoration at the given position, with the given match, should render
|
||||
*/
|
||||
shouldRender(decorationFrom: number, decorationTo: number, match: RegExpMatchArray): boolean {
|
||||
const view = this.view;
|
||||
|
||||
// Bail if it's not live preview mode
|
||||
if (!this.isLivePreview()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isLivePreview(state: EditorView["state"]): boolean {
|
||||
return state.field(editorLivePreviewField);
|
||||
}
|
||||
// Check if it's not an allowed URL
|
||||
if (!isAllowedPath(match[0])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're in a codeblock
|
||||
// Note, codeblock check is more expensive than some others,
|
||||
// But we do it first to ensure this.inCodeblock remains accurate
|
||||
const lastLine = view.state.doc.lineAt(this.lastMatchEnd).number;
|
||||
let currentLine = view.state.doc.lineAt(decorationFrom).number;
|
||||
while (currentLine >= lastLine) {
|
||||
const line = view.state.doc.line(currentLine);
|
||||
/**
|
||||
* This is somewhat naive; a four tick codeblock ````
|
||||
* that contains a three tick codeblock ``` will be treated
|
||||
* as ```` open, ``` close, which is not correct, and can
|
||||
* lead to rendering tags where we shouldn't. This could be
|
||||
* fixed with a more complex notion of "inCodeblock", keeping
|
||||
* track of not just the last fence but also the fence content
|
||||
* so we can treat any three tick fences as not changing the
|
||||
* state if they follow a four tick fence, for example.
|
||||
*
|
||||
* However, there may also be a better way to keep track of
|
||||
* open / close fences. See this example, but it didn't work
|
||||
* for me:
|
||||
* https://github.com/OlegWock/obsidian-emera/blob/master/src/processors/block-jsx-processor.ts#L26
|
||||
*/
|
||||
if (line.text.trim().startsWith("```")) {
|
||||
this.inCodeblock = !this.inCodeblock;
|
||||
}
|
||||
currentLine = line.number - 1;
|
||||
}
|
||||
|
||||
if (this.inCodeblock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldRender(view: EditorView, decorationFrom: number, decorationTo: number, match: RegExpMatchArray) {
|
||||
// Ignore matches inside a markdown link
|
||||
// TODO: This can probably be removed now that the regex is updated
|
||||
const input = match.input ?? "";
|
||||
const index = match.index ?? 0;
|
||||
const matchValue = match[0];
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
const endIndex = index + matchValue.length;
|
||||
if (input[index - 1] === "(" && matchValue.endsWith(")")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is within an inline code span
|
||||
let inInlineCode = false;
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (input.charAt(i) === "`") {
|
||||
inInlineCode = !inInlineCode;
|
||||
}
|
||||
}
|
||||
if (inInlineCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for cursors / selections inside the widget
|
||||
const overlap = view.state.selection.ranges.some((r) => {
|
||||
if (r.from <= decorationFrom) {
|
||||
return r.to >= decorationFrom;
|
||||
|
|
@ -92,41 +153,29 @@ export function createInlineViewPlugin(_plugin: GithubLinkPlugin) {
|
|||
return r.from <= decorationTo;
|
||||
}
|
||||
});
|
||||
return !overlap && this.isLivePreview(view.state);
|
||||
if (overlap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const InlineViewPluginSpec: PluginSpec<InlineViewPluginValue> = {
|
||||
decorations: (plugin) => {
|
||||
// Update and return decorations for the CodeMirror view
|
||||
return ViewPlugin.fromClass(InlineViewPluginValue, {
|
||||
decorations: (instance) => {
|
||||
// Set initial state for code block search
|
||||
instance.lastMatchEnd = 0;
|
||||
instance.inCodeblock = false;
|
||||
|
||||
return plugin.decorations.update({
|
||||
filter: (rangeFrom, rangeTo, deco) => {
|
||||
const widget = (deco.spec as DecoSpec).widget;
|
||||
if (widget && widget.error) {
|
||||
return false;
|
||||
}
|
||||
// Check if the range is collapsed (cursor position)
|
||||
return (
|
||||
rangeFrom === rangeTo ||
|
||||
// Check if there are no overlapping selection ranges
|
||||
!plugin.view.state.selection.ranges.filter((selectionRange) => {
|
||||
// Determine the start and end positions of the selection range
|
||||
const selectionStart = selectionRange.from;
|
||||
const selectionEnd = selectionRange.to;
|
||||
|
||||
// Check if the selection range overlaps with the specified range
|
||||
if (selectionStart <= rangeFrom) {
|
||||
return selectionEnd >= rangeFrom; // Overlapping condition
|
||||
} else {
|
||||
return selectionStart <= rangeTo; // Overlapping condition
|
||||
}
|
||||
}).length
|
||||
);
|
||||
// Filter all potential decorations
|
||||
return instance.inlineTags.update({
|
||||
filter: (from, to, deco) => {
|
||||
const spec = deco.spec as DecoSpec;
|
||||
const shouldRender = !spec.widget || instance.shouldRender(from, to, spec.widget.match);
|
||||
instance.lastMatchEnd = to;
|
||||
return shouldRender;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return ViewPlugin.fromClass(InlineViewPluginValue, InlineViewPluginSpec);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
91
src/plugin.spec.ts
Normal file
91
src/plugin.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { expect, jest, test, describe, beforeEach } from "@jest/globals";
|
||||
import type { Plugin, RequestUrlResponse } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import type { PluginMock } from "../__mocks__/obsidian/Plugin";
|
||||
import * as manifest from "../manifest.json";
|
||||
import { GithubLinkPlugin, PluginData, PluginSettings, getCache } from "./plugin";
|
||||
import type { GithubLinkPluginSettings } from "./settings";
|
||||
import { DEFAULT_SETTINGS } from "./settings";
|
||||
import { CacheEntry, RequestCache } from "./github/cache";
|
||||
import { LogLevel } from "./logger";
|
||||
import { DATA_VERSION } from "./settings/types";
|
||||
|
||||
jest.mock("./settings/settings-tab");
|
||||
|
||||
function mockedPlugin(plugin: GithubLinkPlugin): PluginMock {
|
||||
return plugin as Plugin as PluginMock;
|
||||
}
|
||||
|
||||
describe("GithubLinkPlugin", () => {
|
||||
let app: App;
|
||||
let plugin: GithubLinkPlugin;
|
||||
|
||||
beforeEach(() => {
|
||||
app = new App();
|
||||
});
|
||||
|
||||
test("should create", () => {
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
expect(plugin).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should create cache instance", async () => {
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
await plugin.onload();
|
||||
expect(getCache() instanceof RequestCache).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should load all required components", async () => {
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
await plugin.onload();
|
||||
expect(plugin.addSettingTab).toHaveBeenCalled();
|
||||
expect(plugin.registerMarkdownPostProcessor).toHaveBeenCalled();
|
||||
expect(plugin.registerEditorExtension).toHaveBeenCalled();
|
||||
expect(plugin.registerMarkdownCodeBlockProcessor).toHaveBeenCalled();
|
||||
expect(plugin.registerInterval).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadData", () => {
|
||||
test("should initialize default settings", async () => {
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
await plugin.onload();
|
||||
expect(PluginData).toBeDefined();
|
||||
expect(PluginData.cache).toEqual(null);
|
||||
expect(PluginData.settings).toEqual(DEFAULT_SETTINGS);
|
||||
expect(PluginData.dataVersion).toEqual(DATA_VERSION);
|
||||
});
|
||||
|
||||
test("should load stored cache", async () => {
|
||||
const cacheEntry = new CacheEntry(
|
||||
{ url: "mock" },
|
||||
{ json: "mock", headers: {} } as RequestUrlResponse,
|
||||
new Date(),
|
||||
null,
|
||||
null,
|
||||
);
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
mockedPlugin(plugin).data = { cache: [cacheEntry.toJSON()], dataVersion: DATA_VERSION };
|
||||
await plugin.onload();
|
||||
expect(PluginData.cache).toEqual([cacheEntry.toJSON()]);
|
||||
expect(getCache().get(cacheEntry.request)).toEqual(cacheEntry);
|
||||
});
|
||||
|
||||
test.each<{ stored: Partial<GithubLinkPluginSettings>; name: string }>([
|
||||
{ stored: { cacheIntervalSeconds: 69 }, name: "cacheIntervalSeconds" },
|
||||
{ stored: { defaultPageSize: 69 }, name: "defaultPageSize" },
|
||||
{ stored: { tagTooltips: !DEFAULT_SETTINGS.tagTooltips }, name: "tagTooltips" },
|
||||
{ stored: { minRequestSeconds: 69 }, name: "minRequestSeconds" },
|
||||
{ stored: { logLevel: LogLevel.Debug }, name: "logLevel" },
|
||||
{ stored: { showPagination: !DEFAULT_SETTINGS.showPagination }, name: "showPagination" },
|
||||
{ stored: { showRefresh: !DEFAULT_SETTINGS.showRefresh }, name: "showRefresh" },
|
||||
{ stored: { showExternalLink: !DEFAULT_SETTINGS.showExternalLink }, name: "showExternalLink" },
|
||||
])("should merge stored and default settings ($name)", async ({ stored }) => {
|
||||
plugin = new GithubLinkPlugin(app, manifest);
|
||||
mockedPlugin(plugin).data = { settings: stored };
|
||||
await plugin.onload();
|
||||
for (const [k, v] of Object.entries(stored)) {
|
||||
expect(PluginSettings[k as keyof GithubLinkPluginSettings]).toEqual(v);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Notice, Plugin } from "obsidian";
|
||||
import { DEFAULT_SETTINGS, GithubLinkPluginSettingsTab } from "./settings";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
import type { GithubLinkPluginData, GithubLinkPluginSettings } from "./settings";
|
||||
import { InlineRenderer } from "./inline/inline";
|
||||
import { Plugin } from "obsidian";
|
||||
import { QueryProcessor } from "./query/processor";
|
||||
import { createInlineViewPlugin } from "./inline/view-plugin";
|
||||
import { RequestCache } from "./github/cache";
|
||||
import { QueryProcessor } from "./query/processor";
|
||||
import { DATA_VERSION } from "./settings/types";
|
||||
|
||||
export const PluginSettings: GithubLinkPluginSettings = { ...DEFAULT_SETTINGS };
|
||||
export const PluginData: GithubLinkPluginData = { cache: null, settings: PluginSettings };
|
||||
export const PluginData: GithubLinkPluginData = { cache: null, settings: PluginSettings, dataVersion: DATA_VERSION };
|
||||
export const logger = new Logger();
|
||||
let cache: RequestCache;
|
||||
export function getCache(): RequestCache {
|
||||
|
|
@ -19,28 +22,7 @@ export function getCache(): RequestCache {
|
|||
export class GithubLinkPlugin extends Plugin {
|
||||
public cacheInterval: number | undefined;
|
||||
async onload() {
|
||||
let data = (await this.loadData()) || {};
|
||||
|
||||
// Migrate settings from data root to settings -- remove in v1.0.0
|
||||
if (data.accounts) {
|
||||
const newSettings: GithubLinkPluginSettings = {
|
||||
accounts: data.accounts ?? PluginSettings.accounts,
|
||||
cacheIntervalSeconds: data.cacheIntervalSeconds ?? PluginSettings.cacheIntervalSeconds,
|
||||
defaultPageSize: data.defaultPageSize ?? PluginSettings.defaultPageSize,
|
||||
logLevel: data.logLevel ?? PluginSettings.logLevel,
|
||||
maxCacheAgeHours: data.maxCacheAgeHours ?? PluginSettings.maxCacheAgeHours,
|
||||
minRequestSeconds: data.minRequestSeconds ?? PluginSettings.minRequestSeconds,
|
||||
tagShowPRMergeable: data.tagShowPRMergeable ?? PluginSettings.tagShowPRMergeable,
|
||||
tagTooltips: data.tagTooltips ?? PluginSettings.tagTooltips,
|
||||
defaultAccount: data.defaultAccount ?? PluginSettings.defaultAccount,
|
||||
};
|
||||
const newData: GithubLinkPluginData = {
|
||||
cache: data.cache ?? PluginData.cache,
|
||||
settings: newSettings,
|
||||
};
|
||||
await this.saveData(newData);
|
||||
data = newData;
|
||||
}
|
||||
const data = (await this.loadData()) || {};
|
||||
|
||||
Object.assign(PluginSettings, data.settings);
|
||||
Object.assign(PluginData, data);
|
||||
|
|
@ -48,12 +30,24 @@ export class GithubLinkPlugin extends Plugin {
|
|||
|
||||
cache = new RequestCache(PluginData.cache);
|
||||
|
||||
if (data.dataVersion === undefined || PluginData.dataVersion < DATA_VERSION) {
|
||||
// Always clear cache when data version changes
|
||||
const entriesDeleted = cache.clean(new Date());
|
||||
PluginData.cache = null;
|
||||
PluginData.dataVersion = DATA_VERSION;
|
||||
await this.saveData(PluginData);
|
||||
new Notice(
|
||||
`GitHub link data schema migrated to version ${DATA_VERSION}. Removed ${entriesDeleted} stored items from GitHub Link cache.`,
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
// Clean cache
|
||||
const maxAge = new Date(new Date().getTime() - PluginSettings.maxCacheAgeHours * 60 * 60 * 1000);
|
||||
const entriesDeleted = cache.clean(maxAge);
|
||||
if (entriesDeleted > 0) {
|
||||
PluginData.cache = cache.toJSON();
|
||||
await this.saveData(PluginSettings);
|
||||
await this.saveData(PluginData);
|
||||
logger.info(`Cleaned ${entriesDeleted} entries from request cache.`);
|
||||
}
|
||||
|
||||
|
|
@ -70,16 +64,20 @@ export class GithubLinkPlugin extends Plugin {
|
|||
* Save cache at regular interval
|
||||
*/
|
||||
public setCacheInterval(): void {
|
||||
const checkCache = async () => {
|
||||
logger.debug("Checking if cache needs a save.");
|
||||
if (cache.cacheUpdated) {
|
||||
PluginData.cache = cache.toJSON();
|
||||
await this.saveData(PluginData);
|
||||
cache.cacheUpdated = false;
|
||||
logger.info(`Saved request cache with ${PluginData.cache?.length} items.`);
|
||||
}
|
||||
};
|
||||
|
||||
window.clearInterval(this.cacheInterval);
|
||||
this.cacheInterval = this.registerInterval(
|
||||
window.setInterval(async () => {
|
||||
logger.debug("Checking if cache needs a save.");
|
||||
if (cache.cacheUpdated) {
|
||||
PluginData.cache = cache.toJSON();
|
||||
await this.saveData(PluginData);
|
||||
cache.cacheUpdated = false;
|
||||
logger.info(`Saved request cache with ${PluginData.cache?.length} items.`);
|
||||
}
|
||||
window.setInterval(() => {
|
||||
void checkCache();
|
||||
}, PluginSettings.cacheIntervalSeconds * 1000),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import type { IssueSearchResponse } from "src/github/response";
|
||||
import { parseUrl, repoAPIToBrowserUrl } from "src/github/url-parse";
|
||||
import { DateFormat } from "src/util";
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import { parseUrl, repoAPIToBrowserUrl } from "../../github/url-parse";
|
||||
|
||||
import { DateFormat } from "../../util";
|
||||
import type { IssueListResponse, UserResponse } from "../../github/response";
|
||||
import type { TableResult } from "../types";
|
||||
|
||||
export interface ColumnGetter<T> {
|
||||
header: string;
|
||||
cell: (row: T, el: HTMLTableCellElement) => void | Promise<void>;
|
||||
}
|
||||
export type ColumnsMap<T> = Record<string, ColumnGetter<T>>;
|
||||
export type ColumnsMap = Record<string, ColumnGetter<TableResult[number]>>;
|
||||
|
||||
export function DateCell(value: string | undefined | null, el: HTMLTableCellElement) {
|
||||
el.classList.add("github-link-table-date");
|
||||
|
|
@ -24,34 +28,48 @@ export function DateCell(value: string | undefined | null, el: HTMLTableCellElem
|
|||
el.setText(DateFormat.DATE_SHORT.format(asDate));
|
||||
}
|
||||
|
||||
export function UserCell(user: UserResponse, el: HTMLElement): void {
|
||||
const anchor = el.createEl("a", {
|
||||
cls: "github-link-table-author",
|
||||
href: user?.html_url ?? "#",
|
||||
attr: { target: "_blank" },
|
||||
});
|
||||
if (user?.avatar_url) {
|
||||
anchor.createEl("img", { cls: "github-link-table-avatar", attr: { src: user.avatar_url } });
|
||||
}
|
||||
anchor.createSpan({ text: user?.login });
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue and PR columns share types, so some columns are shared
|
||||
*/
|
||||
export const CommonIssuePRColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
|
||||
export const CommonIssuePRColumns: ColumnsMap = {
|
||||
number: {
|
||||
header: "Number",
|
||||
cell: (row, el) => {
|
||||
el.classList.add("github-link-table-issue-number");
|
||||
el.createEl("a", { text: `#${row.number}`, href: row.html_url });
|
||||
el.createEl("a", { text: `#${row.number}`, href: row.html_url, attr: { target: "_blank" } });
|
||||
},
|
||||
},
|
||||
repo: {
|
||||
header: "Repo",
|
||||
cell: (row, el) => {
|
||||
el.classList.add("github-link-table-repo");
|
||||
const url = repoAPIToBrowserUrl(row.repository_url);
|
||||
const url = repoAPIToBrowserUrl((row as IssueListResponse[number]).repository_url);
|
||||
const parsed = parseUrl(url);
|
||||
el.createEl("a", { text: parsed.repo, href: url });
|
||||
el.createEl("a", { text: parsed?.repo ?? "Repo", href: url, attr: { target: "_blank" } });
|
||||
},
|
||||
},
|
||||
author: {
|
||||
header: "Author",
|
||||
cell: (row, el) => {
|
||||
const anchor = el.createEl("a", { cls: "github-link-table-author" });
|
||||
if (row.user?.avatar_url) {
|
||||
anchor.createEl("img", { cls: "github-link-table-avatar", attr: { src: row.user.avatar_url } });
|
||||
}
|
||||
anchor.createSpan({ text: row.user?.login });
|
||||
UserCell(row.user, el);
|
||||
},
|
||||
},
|
||||
assignee: {
|
||||
header: "Assignee",
|
||||
cell: (row, el) => {
|
||||
UserCell(row.assignee, el);
|
||||
},
|
||||
},
|
||||
created: {
|
||||
|
|
@ -72,4 +90,22 @@ export const CommonIssuePRColumns: ColumnsMap<IssueSearchResponse["items"][numbe
|
|||
DateCell(row.closed_at, el);
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
header: "Labels",
|
||||
cell: (row, el) => {
|
||||
const wrapper = el.createDiv({ cls: "github-link-table-labels" });
|
||||
for (const label of row.labels ?? []) {
|
||||
// When would the label just be a string?
|
||||
if (typeof label !== "string") {
|
||||
const labelEl = wrapper.createSpan({
|
||||
cls: "github-link-table-label",
|
||||
text: label.name,
|
||||
});
|
||||
if (label.color) {
|
||||
labelEl.style.setProperty("--status-color", `#${label.color}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { QueryType } from "../params";
|
||||
import type { QueryType } from "../types";
|
||||
import { IssueColumns } from "./issue";
|
||||
import { PullRequestColumns } from "./pull-request";
|
||||
import { RepoColumns } from "./repo";
|
||||
|
||||
/*
|
||||
We have to do this workaround with the QueryType enum because ts-jest does not support
|
||||
using constant enums like this; see https://github.com/kulshekhar/ts-jest/pull/308/files
|
||||
*/
|
||||
|
||||
export const DEFAULT_COLUMNS = {
|
||||
[QueryType.Issue]: ["number", "title", "author", "created", "status"],
|
||||
[QueryType.PullRequest]: ["number", "title", "author", "created", "status"],
|
||||
[QueryType.Repo]: [],
|
||||
["issue" as QueryType]: ["number", "title", "author", "created", "status"],
|
||||
["pull-request" as QueryType]: ["number", "title", "author", "created", "status"],
|
||||
["repo" as QueryType]: [],
|
||||
};
|
||||
|
||||
export const ALL_COLUMNS = {
|
||||
["issue" as QueryType]: IssueColumns,
|
||||
["pull-request" as QueryType]: PullRequestColumns,
|
||||
["repo" as QueryType]: RepoColumns,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { getSearchResultIssueStatus, type IssueSearchResponse } from "src/github/response";
|
||||
import type { IssueListResponse, IssueSearchResponse } from "../../github/response";
|
||||
import { getSearchResultIssueStatus } from "../../github/response";
|
||||
import { setIssueIcon } from "../../icon";
|
||||
import { titleCase } from "../../util";
|
||||
import { createTag } from "../../inline/inline";
|
||||
import { getPRForIssue } from "../../github/github";
|
||||
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
|
||||
import { setIssueIcon } from "src/icon";
|
||||
import { titleCase } from "src/util";
|
||||
import { createTag } from "src/inline/inline";
|
||||
import { getPRForIssue } from "src/github/github";
|
||||
|
||||
export const IssueColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
|
||||
export const IssueColumns: ColumnsMap = {
|
||||
...CommonIssuePRColumns,
|
||||
status: {
|
||||
header: "Status",
|
||||
|
|
@ -14,12 +15,18 @@ export const IssueColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
|
|||
const status = getSearchResultIssueStatus(row);
|
||||
const icon = wrapper.createSpan({ cls: "github-link-status-icon" });
|
||||
setIssueIcon(icon, status);
|
||||
wrapper.createSpan({ text: row.state_reason === "not_planned" ? "Not Planned" : titleCase(status) });
|
||||
wrapper.createSpan({
|
||||
text:
|
||||
(row as IssueSearchResponse["items"][number]).state_reason === "not_planned"
|
||||
? "Not Planned"
|
||||
: titleCase(status),
|
||||
});
|
||||
},
|
||||
},
|
||||
pr: {
|
||||
header: "PR",
|
||||
cell: async (row, el) => {
|
||||
cell: async (_row, el) => {
|
||||
const row = _row as IssueListResponse[number];
|
||||
// TODO: Figure out how to include org here for private repos
|
||||
if (!row.timeline_url) {
|
||||
return;
|
||||
|
|
@ -29,7 +36,9 @@ export const IssueColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
|
|||
return;
|
||||
}
|
||||
const tag = createTag(pullRequestUrl);
|
||||
el.appendChild(tag);
|
||||
if (tag) {
|
||||
el.appendChild(tag);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { getSearchResultIssueStatus, IssueStatus, type IssueSearchResponse } from "src/github/response";
|
||||
import { getSearchResultIssueStatus, IssueStatus } from "../../github/response";
|
||||
import { setPRIcon } from "../../icon";
|
||||
import { titleCase } from "../../util";
|
||||
import { CommonIssuePRColumns, type ColumnsMap } from "./base";
|
||||
import { setPRIcon } from "src/icon";
|
||||
import { titleCase } from "src/util";
|
||||
|
||||
export const PullRequestColumns: ColumnsMap<IssueSearchResponse["items"][number]> = {
|
||||
export const PullRequestColumns: ColumnsMap = {
|
||||
...CommonIssuePRColumns,
|
||||
status: {
|
||||
header: "Status",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { RepoSearchResponse } from "src/github/response";
|
||||
import type { ColumnsMap } from "./base";
|
||||
|
||||
export const RepoColumns: ColumnsMap<RepoSearchResponse["items"][number]> = {};
|
||||
export const RepoColumns: ColumnsMap = {};
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import { getProp, titleCase } from "src/util";
|
||||
|
||||
import type { BaseParams } from "./params";
|
||||
import { DEFAULT_COLUMNS } from "./column/defaults";
|
||||
import { IssueColumns } from "./column/issue";
|
||||
import { PullRequestColumns } from "./column/pull-request";
|
||||
import { QueryType } from "./params";
|
||||
import { RepoColumns } from "./column/repo";
|
||||
import { setIcon } from "obsidian";
|
||||
|
||||
const ALL_COLUMNS = {
|
||||
[QueryType.PullRequest]: PullRequestColumns,
|
||||
[QueryType.Issue]: IssueColumns,
|
||||
[QueryType.Repo]: RepoColumns,
|
||||
};
|
||||
|
||||
export async function renderTable<T extends { items: unknown[] } | unknown[]>(
|
||||
params: BaseParams,
|
||||
result: T,
|
||||
el: HTMLElement,
|
||||
renderFn: (element: HTMLElement, skipCache?: boolean) => Promise<void>,
|
||||
externalLink?: string,
|
||||
) {
|
||||
el.empty();
|
||||
const tableWrapper = el.createDiv({ cls: "github-link-table-wrapper" });
|
||||
const tableScrollWrapper = tableWrapper.createDiv({ cls: "github-link-table-scroll-wrapper" });
|
||||
const table = tableScrollWrapper.createEl("table", { cls: "github-link-table" });
|
||||
|
||||
if (params.refresh) {
|
||||
const refresh = tableWrapper.createDiv({ cls: "github-link-table-refresh" });
|
||||
if (externalLink) {
|
||||
refresh.createEl("a", {
|
||||
cls: "github-link-table-refresh-external-link",
|
||||
text: "View on GitHub",
|
||||
href: externalLink,
|
||||
});
|
||||
}
|
||||
const refreshButton = refresh.createEl("button", {
|
||||
cls: "clickable-icon",
|
||||
attr: { "aria-label": "Refresh Results" },
|
||||
});
|
||||
refreshButton.addEventListener("click", () => renderFn(el, true));
|
||||
setIcon(refreshButton, "refresh-cw");
|
||||
}
|
||||
|
||||
const thead = table.createEl("thead");
|
||||
let columns = params.columns;
|
||||
if (!columns || columns.length === 0) {
|
||||
columns = DEFAULT_COLUMNS[params.queryType];
|
||||
}
|
||||
|
||||
// Ensure columns are lowercase
|
||||
columns = columns.map((c) => c.toLowerCase());
|
||||
|
||||
for (const col of columns) {
|
||||
const th = thead.createEl("th");
|
||||
// Get predefined header if available
|
||||
th.setText(ALL_COLUMNS[params.queryType][col]?.header ?? titleCase(col));
|
||||
}
|
||||
const tbody = table.createEl("tbody");
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
for (const row of items) {
|
||||
const tr = tbody.createEl("tr");
|
||||
for (const col of columns) {
|
||||
const cell = tr.createEl("td");
|
||||
const renderer = ALL_COLUMNS[params.queryType][col];
|
||||
if (renderer) {
|
||||
renderer.cell(row, cell);
|
||||
} else {
|
||||
const cellVal = getProp(row, col);
|
||||
if (cellVal !== null) {
|
||||
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
|
||||
} else {
|
||||
cell.setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import type { IssueListParams, PullListParams } from "src/github/response";
|
||||
|
||||
import { parseYaml } from "obsidian";
|
||||
|
||||
export enum OutputType {
|
||||
Table = "table",
|
||||
}
|
||||
|
||||
export enum QueryType {
|
||||
PullRequest = "pull-request",
|
||||
Issue = "issue",
|
||||
Repo = "repo",
|
||||
}
|
||||
|
||||
export interface BaseParams {
|
||||
outputType: OutputType;
|
||||
queryType: QueryType;
|
||||
columns: string[];
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
export type TableQueryParams<T> = BaseParams & { query: string; org?: string } & T & { q: never };
|
||||
export type TableParams<T extends IssueListParams | PullListParams> = T & BaseParams;
|
||||
|
||||
export function processParams(source: string): BaseParams | null {
|
||||
let params: BaseParams;
|
||||
try {
|
||||
params = parseYaml(source);
|
||||
} catch (e) {
|
||||
console.error(`Github Link: YAML Parsing failed, attempting simplistic parsing\n${e}`);
|
||||
params = Object.fromEntries(source.split("\n").map((l) => l.split(/:\s?/)));
|
||||
}
|
||||
|
||||
params.refresh = params.refresh ?? true;
|
||||
|
||||
return params ?? null;
|
||||
}
|
||||
|
||||
export function isTableQueryParams<T>(params: BaseParams): params is TableQueryParams<T> {
|
||||
return params.outputType === OutputType.Table && Boolean((params as TableQueryParams<T>).query);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function isTableParams(params: BaseParams): params is TableParams<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return params.outputType === OutputType.Table && !(params as TableQueryParams<any>).query;
|
||||
}
|
||||
|
|
@ -1,50 +1,11 @@
|
|||
import { type MarkdownPostProcessorContext } from "obsidian";
|
||||
import type { TableParams, TableQueryParams } from "./params";
|
||||
import { QueryType, isTableParams, isTableQueryParams, processParams } from "./params";
|
||||
import { renderTable } from "./output";
|
||||
import { searchIssues, getIssuesForRepo, getMyIssues, getPullRequestsForRepo } from "src/github/github";
|
||||
import type { IssueListParams, IssueSearchParams, PullListParams } from "src/github/response";
|
||||
import type { MarkdownPostProcessorContext } from "obsidian";
|
||||
import { GithubQuery } from "./query";
|
||||
|
||||
export async function QueryProcessor(
|
||||
source: string,
|
||||
el: HTMLElement,
|
||||
_ctx: MarkdownPostProcessorContext,
|
||||
): Promise<void> {
|
||||
const params = processParams(source);
|
||||
|
||||
if (!params) {
|
||||
// TODO: show an error instead
|
||||
el.setText(source);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderFn = async (element: HTMLElement, skipCache = false) => {
|
||||
let response: { items: unknown[] } | unknown[] | undefined = undefined;
|
||||
let externalLink: string | undefined;
|
||||
if (isTableQueryParams(params)) {
|
||||
if (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest) {
|
||||
const queryParams = params as TableQueryParams<IssueSearchParams>;
|
||||
response = await searchIssues(params, params.query, queryParams.org, skipCache);
|
||||
externalLink = `https://github.com/search?q=${encodeURIComponent(params.query)}`;
|
||||
}
|
||||
} else if (isTableParams(params)) {
|
||||
if (params.queryType === QueryType.Issue) {
|
||||
const issueParams = params as TableParams<IssueListParams>;
|
||||
if (issueParams.org && issueParams.repo) {
|
||||
response = await getIssuesForRepo(issueParams, issueParams.org, issueParams.repo, skipCache);
|
||||
} else {
|
||||
response = await getMyIssues(issueParams, issueParams.org, skipCache);
|
||||
}
|
||||
} else if (params.queryType === QueryType.PullRequest) {
|
||||
const pullParams = params as TableParams<PullListParams>;
|
||||
if (pullParams.org && pullParams.repo) {
|
||||
response = await getPullRequestsForRepo(pullParams, pullParams.org, pullParams.repo);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response) {
|
||||
renderTable(params, response, element, renderFn, externalLink);
|
||||
}
|
||||
};
|
||||
await renderFn(el);
|
||||
const query = new GithubQuery(el);
|
||||
await query.init(source);
|
||||
}
|
||||
|
|
|
|||
231
src/query/query.ts
Normal file
231
src/query/query.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { parseYaml, setIcon } from "obsidian";
|
||||
import {
|
||||
searchIssues,
|
||||
getIssuesForRepo,
|
||||
getMyIssues,
|
||||
getPullRequestsForRepo,
|
||||
getIssuesForOrganization,
|
||||
} from "../github/github";
|
||||
import type { MaybePaginated, PaginationMeta } from "../github/response";
|
||||
import { PluginSettings } from "../plugin";
|
||||
import { getProp, isEqual, titleCase } from "../util";
|
||||
import { ALL_COLUMNS, DEFAULT_COLUMNS } from "./column/defaults";
|
||||
import type { QueryParams, TableResult } from "./types";
|
||||
import { OutputType, QueryType } from "./types";
|
||||
|
||||
export class GithubQuery {
|
||||
private params!: QueryParams;
|
||||
private result: TableResult | null = null;
|
||||
private resultMeta: PaginationMeta | null = null;
|
||||
|
||||
constructor(private readonly hostElement: HTMLElement) {}
|
||||
|
||||
public async init(source: string): Promise<void> {
|
||||
const parsedParams = this.parseCodeblock(source);
|
||||
if (!parsedParams) {
|
||||
console.error(`Github Link: simplistic parsing failed`);
|
||||
} else {
|
||||
await this.setParams(parsedParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setting the parameters triggers calling the API
|
||||
*/
|
||||
public async setParams(newParams: QueryParams = this.params, forceUpdate = false): Promise<void> {
|
||||
const currentParams = this.params;
|
||||
this.params = newParams;
|
||||
if (forceUpdate || !isEqual(currentParams, newParams)) {
|
||||
const result = await this.executeQuery(forceUpdate);
|
||||
if (result) {
|
||||
this.setResult(result.response, result.meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setResult(result: TableResult, meta: PaginationMeta): void {
|
||||
this.result = result;
|
||||
this.resultMeta = meta;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public parseCodeblock(source: string): QueryParams | null {
|
||||
let params: QueryParams | null;
|
||||
try {
|
||||
params = parseYaml(source) as QueryParams;
|
||||
} catch (e) {
|
||||
console.error(`Github Link: YAML Parsing failed, attempting simplistic parsing`);
|
||||
console.error(e);
|
||||
params = Object.fromEntries(source.split("\n").map((l) => l.split(/:\s?/))) as QueryParams;
|
||||
}
|
||||
return params ?? null;
|
||||
}
|
||||
|
||||
public async executeQuery(skipCache = false): Promise<MaybePaginated<TableResult> | null> {
|
||||
const params = this.params;
|
||||
if (params.outputType === OutputType.Table) {
|
||||
// Custom Query
|
||||
if (params.query && (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest)) {
|
||||
const { meta, response } = await searchIssues(params, params.query, params.org, skipCache);
|
||||
return { meta, response: response.items };
|
||||
}
|
||||
// Issue query with org and repo provided
|
||||
else if (params.queryType === QueryType.Issue && params.org && params.repo) {
|
||||
return await getIssuesForRepo(params, params.org, params.repo, skipCache);
|
||||
}
|
||||
// Issue query with only org provided
|
||||
else if (params.queryType === QueryType.Issue && params.org) {
|
||||
// TODO: Handle error case here if provided org cannot be found
|
||||
return await getIssuesForOrganization(params, params.org, skipCache);
|
||||
}
|
||||
// Issue query without org or repo provided
|
||||
else if (params.queryType === QueryType.Issue) {
|
||||
return await getMyIssues(params, params.org, skipCache);
|
||||
}
|
||||
// Pull request query with org and repo provided
|
||||
else if (params.queryType === QueryType.PullRequest && params.org && params.repo) {
|
||||
return await getPullRequestsForRepo(params, params.org, params.repo, skipCache);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
if (!this.result) {
|
||||
throw new Error("Attempted to render table before there was a result.");
|
||||
}
|
||||
|
||||
this.hostElement.empty();
|
||||
const tableWrapper = this.hostElement.createDiv({ cls: "github-link-table-wrapper" });
|
||||
const tableScrollWrapper = tableWrapper.createDiv({ cls: "github-link-table-scroll-wrapper" });
|
||||
const table = tableScrollWrapper.createEl("table", { cls: "github-link-table" });
|
||||
|
||||
const queryType = this.params.queryType;
|
||||
|
||||
// Use default columns if none are provided
|
||||
let columns = this.params.columns;
|
||||
if (!columns || columns.length === 0) {
|
||||
columns = DEFAULT_COLUMNS[queryType];
|
||||
}
|
||||
|
||||
// Ensure columns are lowercase
|
||||
columns = columns.map((c) => c.toLowerCase());
|
||||
|
||||
// Render
|
||||
this.renderFooter(this.params, this.resultMeta, tableWrapper);
|
||||
this.renderHeader(table, queryType, columns);
|
||||
this.renderBody(table, queryType, columns, this.result);
|
||||
}
|
||||
|
||||
private renderHeader(table: HTMLTableElement, queryType: QueryType, columns: string[]): void {
|
||||
const thead = table.createEl("thead");
|
||||
for (const col of columns) {
|
||||
const th = thead.createEl("th");
|
||||
// Get predefined header if available, otherwise try and create a title
|
||||
th.setText(ALL_COLUMNS[queryType][col]?.header ?? titleCase(col));
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody(table: HTMLTableElement, queryType: QueryType, columns: string[], result: TableResult): void {
|
||||
const tbody = table.createEl("tbody");
|
||||
for (const row of result) {
|
||||
const tr = tbody.createEl("tr");
|
||||
for (const col of columns) {
|
||||
this.renderCell(tr, queryType, col, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderCell(tr: HTMLTableRowElement, queryType: QueryType, column: string, row: TableResult[number]): void {
|
||||
const cell = tr.createEl("td");
|
||||
const renderer = ALL_COLUMNS[queryType][column];
|
||||
if (renderer) {
|
||||
void renderer.cell(row, cell);
|
||||
} else {
|
||||
const cellVal = getProp(row, column);
|
||||
if (cellVal !== null) {
|
||||
cell.setText(typeof cellVal === "string" ? cellVal : JSON.stringify(cellVal));
|
||||
} else {
|
||||
cell.setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderFooter(params: QueryParams, meta: PaginationMeta | null, parent: HTMLElement): void {
|
||||
const footer = parent.createDiv({ cls: "github-link-table-footer" });
|
||||
|
||||
// Add external link to footer if available
|
||||
const externalLink = this.getExternalLink(params);
|
||||
if (externalLink && PluginSettings.showExternalLink) {
|
||||
footer.createEl("a", {
|
||||
cls: "github-link-table-footer-external-link",
|
||||
text: "View on GitHub",
|
||||
href: externalLink,
|
||||
attr: { target: "_blank" },
|
||||
});
|
||||
}
|
||||
|
||||
this.renderPagination(meta, footer);
|
||||
|
||||
if (PluginSettings.showRefresh) {
|
||||
const refreshButton = footer.createEl("button", {
|
||||
cls: "clickable-icon",
|
||||
attr: { "aria-label": "Refresh Results" },
|
||||
});
|
||||
refreshButton.addEventListener("click", () => {
|
||||
void this.setParams(this.params, true);
|
||||
});
|
||||
setIcon(refreshButton, "refresh-cw");
|
||||
}
|
||||
}
|
||||
|
||||
private renderPagination(meta: PaginationMeta | null, parent: HTMLElement): void {
|
||||
if (PluginSettings.showPagination && this.hasSomeRel(meta)) {
|
||||
const pagination = parent.createDiv({ cls: "github-link-table-pagination" });
|
||||
|
||||
// First, previous
|
||||
if (meta?.first && (!meta.prev || meta.prev.page !== meta.first.page)) {
|
||||
const first = pagination.createEl("a", { text: "<<", href: "#", attr: { role: "button" } });
|
||||
first.addEventListener("click", () => {
|
||||
void this.setParams({ ...this.params, page: meta.first?.page });
|
||||
});
|
||||
}
|
||||
if (meta?.prev) {
|
||||
const prev = pagination.createEl("a", { text: meta.prev.page.toString(), href: "#", attr: { role: "button" } });
|
||||
prev.addEventListener("click", () => {
|
||||
void this.setParams({ ...this.params, page: meta.prev?.page });
|
||||
});
|
||||
}
|
||||
|
||||
// Current Page
|
||||
pagination.createSpan({ text: (this.params.page ?? 1).toString() });
|
||||
|
||||
// Next, last
|
||||
if (meta?.next) {
|
||||
const next = pagination.createEl("a", { text: meta.next.page.toString(), href: "#", attr: { role: "button" } });
|
||||
next.addEventListener("click", () => {
|
||||
void this.setParams({ ...this.params, page: meta.next?.page });
|
||||
});
|
||||
}
|
||||
if (meta?.last && (!meta.next || meta.next.page !== meta.last.page)) {
|
||||
const last = pagination.createEl("a", { text: ">>", href: "#", attr: { role: "button" } });
|
||||
last.addEventListener("click", () => {
|
||||
void this.setParams({ ...this.params, page: meta.last?.page });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasSomeRel(meta: PaginationMeta | null): boolean {
|
||||
return Boolean(meta && (meta.first || meta.prev || meta.next || meta.last));
|
||||
}
|
||||
|
||||
private getExternalLink(params: QueryParams): string | null {
|
||||
// Custom search query
|
||||
if (params.query && (params.queryType === QueryType.Issue || params.queryType === QueryType.PullRequest)) {
|
||||
return `https://github.com/search?q=${encodeURIComponent(params.query)}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/query/sort.ts
Normal file
32
src/query/sort.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { IssueSearchParams, IssueListParams, PullListParams } from "../github/response";
|
||||
import type { QueryParams } from "./types";
|
||||
|
||||
/**
|
||||
* Utility function to transform generic param sort into sort for issue search
|
||||
*/
|
||||
export function searchSortFromQuery(params: QueryParams): IssueSearchParams["sort"] {
|
||||
if (params.sort !== "popularity" && params.sort !== "long-running") {
|
||||
return params.sort;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to transform generic param sort into sort for issue list
|
||||
*/
|
||||
export function issueListSortFromQuery(params: QueryParams): IssueListParams["sort"] {
|
||||
if (params.sort && ["created", "updated", "comments"].includes(params.sort)) {
|
||||
return params.sort as IssueListParams["sort"];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to transform generic param sort into sort for pull list
|
||||
*/
|
||||
export function pullListSortFromQuery(params: QueryParams): PullListParams["sort"] {
|
||||
if (params.sort && ["created", "updated", "popularity", "long-running"].includes(params.sort)) {
|
||||
return params.sort as PullListParams["sort"];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
102
src/query/types.ts
Normal file
102
src/query/types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { IssueListResponse, IssueSearchResponse, PullListResponse } from "../github/response";
|
||||
|
||||
export type TableResult = IssueSearchResponse["items"] | IssueListResponse | PullListResponse;
|
||||
|
||||
export enum OutputType {
|
||||
Table = "table",
|
||||
}
|
||||
|
||||
export enum QueryType {
|
||||
PullRequest = "pull-request",
|
||||
Issue = "issue",
|
||||
Repo = "repo",
|
||||
}
|
||||
|
||||
export interface BaseParams {
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all fields are supported by all query types
|
||||
*/
|
||||
export interface QueryParams {
|
||||
outputType: OutputType;
|
||||
queryType: QueryType;
|
||||
columns: string[];
|
||||
|
||||
/**
|
||||
* Custom query. This will override most other options.
|
||||
*/
|
||||
query?: string;
|
||||
|
||||
/**
|
||||
* Pagination page size
|
||||
*/
|
||||
per_page?: number;
|
||||
|
||||
/**
|
||||
* Pagination page number
|
||||
*/
|
||||
page?: number;
|
||||
|
||||
/**
|
||||
* Repository name
|
||||
*/
|
||||
repo?: string;
|
||||
|
||||
/**
|
||||
* Organization or user name
|
||||
*/
|
||||
org?: string;
|
||||
milestone?: string;
|
||||
state?: "open" | "closed" | "all";
|
||||
assignee?: "none" | "*" | string;
|
||||
creator?: string;
|
||||
mentioned?: string;
|
||||
labels?: string | string[];
|
||||
|
||||
/**
|
||||
* "comments" - Issues only
|
||||
* "popularity", "long-running" - Pull requests only
|
||||
* "reactions" and "interactions" - Search only
|
||||
*/
|
||||
sort?:
|
||||
| "created"
|
||||
| "updated"
|
||||
| "comments"
|
||||
| "popularity"
|
||||
| "long-running"
|
||||
| "reactions"
|
||||
| "reactions-+1"
|
||||
| "reactions--1"
|
||||
| "reactions-smile"
|
||||
| "reactions-thinking_face"
|
||||
| "reactions-heart"
|
||||
| "reactions-tada"
|
||||
| "interactions";
|
||||
|
||||
/**
|
||||
* Sort direction, for most queries
|
||||
*/
|
||||
direction?: "asc" | "desc";
|
||||
/**
|
||||
* Sort direction, for search queries
|
||||
*/
|
||||
order?: "asc" | "desc";
|
||||
since?: string;
|
||||
|
||||
/**
|
||||
* Issue filter type
|
||||
*/
|
||||
filter?: "assigned" | "created" | "mentioned" | "subscribed" | "repos" | "all";
|
||||
|
||||
/**
|
||||
* Pull request branch
|
||||
*/
|
||||
head?: string;
|
||||
|
||||
/**
|
||||
* Pull request target
|
||||
*/
|
||||
base?: string;
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import type { App } from "obsidian";
|
||||
import { Setting } from "obsidian";
|
||||
import type { GithubAccount } from "./types";
|
||||
import { AuthModal } from "src/auth-modal";
|
||||
import { auth } from "src/github/auth";
|
||||
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
|
||||
import { AuthModal } from "../auth-modal";
|
||||
import { auth } from "../github/auth";
|
||||
import type { GithubAccount } from "./types";
|
||||
|
||||
export class AccountSettings {
|
||||
authModal: AuthModal | null = null;
|
||||
|
|
@ -14,7 +14,7 @@ export class AccountSettings {
|
|||
private readonly container: HTMLElement,
|
||||
private readonly saveCallback: () => Promise<void>,
|
||||
private readonly displayCallback: () => void,
|
||||
private readonly removeCallback: (account: GithubAccount) => void,
|
||||
private readonly removeCallback: (account: GithubAccount) => Promise<void>,
|
||||
) {}
|
||||
|
||||
public render(accounts: GithubAccount[]): void {
|
||||
|
|
@ -90,7 +90,7 @@ export class AccountSettings {
|
|||
text.setValue(this.newAccount!.clientId ?? "");
|
||||
text.setPlaceholder("Client ID");
|
||||
text.onChange((value) => {
|
||||
this.newAccount!.clientId = value;
|
||||
this.newAccount!.clientId = value.trim();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -103,7 +103,11 @@ export class AccountSettings {
|
|||
.addButton((button) => {
|
||||
button.setButtonText("Generate Token");
|
||||
button.onClick(async () => {
|
||||
const authResult = await auth(this.tokenVerification.bind(this))({
|
||||
const clientId = this.newAccount?.customOAuth ? this.newAccount.clientId : undefined;
|
||||
const authResult = await auth(
|
||||
this.tokenVerification.bind(this),
|
||||
clientId,
|
||||
)({
|
||||
type: "oauth",
|
||||
});
|
||||
this.authModal?.close();
|
||||
|
|
@ -116,7 +120,7 @@ export class AccountSettings {
|
|||
text.setPlaceholder("Personal Access Token / OAuth Token");
|
||||
text.setValue(this.newAccount!.token);
|
||||
text.onChange((value) => {
|
||||
this.newAccount!.token = value;
|
||||
this.newAccount!.token = value.trim();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -145,14 +149,14 @@ export class AccountSettings {
|
|||
text.setValue(account.name);
|
||||
text.onChange((value) => {
|
||||
account.name = value;
|
||||
this.saveCallback();
|
||||
void this.saveCallback();
|
||||
});
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setIcon("trash");
|
||||
button.setTooltip("Delete account");
|
||||
button.onClick(async () => {
|
||||
this.removeCallback(account);
|
||||
await this.removeCallback(account);
|
||||
await this.saveCallback();
|
||||
this.displayCallback();
|
||||
});
|
||||
|
|
@ -165,7 +169,7 @@ export class AccountSettings {
|
|||
text.setValue(account.orgs.join(", "));
|
||||
text.onChange((value) => {
|
||||
account.orgs = value.split(",").map((org) => org.trim());
|
||||
this.saveCallback();
|
||||
void this.saveCallback();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -191,8 +195,8 @@ export class AccountSettings {
|
|||
text.setValue(account.clientId ?? "");
|
||||
text.setPlaceholder("Client ID");
|
||||
text.onChange((value) => {
|
||||
account.clientId = value;
|
||||
this.saveCallback();
|
||||
account.clientId = value.trim();
|
||||
void this.saveCallback();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -223,8 +227,8 @@ export class AccountSettings {
|
|||
text.setPlaceholder("Personal Access Token / OAuth Token");
|
||||
text.setValue(account.token);
|
||||
text.onChange((value) => {
|
||||
account.token = value;
|
||||
this.saveCallback();
|
||||
account.token = value.trim();
|
||||
void this.saveCallback();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { GithubLinkPlugin } from "../plugin";
|
|||
import { LogLevel } from "../logger";
|
||||
import { PluginData, PluginSettings, getCache } from "../plugin";
|
||||
import type { GithubAccount, GithubLinkPluginData } from "./types";
|
||||
import { DEFAULT_SETTINGS } from "./types";
|
||||
import { DATA_VERSION, DEFAULT_SETTINGS } from "./types";
|
||||
import { AccountSettings } from "./account";
|
||||
|
||||
export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
||||
|
|
@ -83,15 +83,55 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
})
|
||||
.addSlider((slider) => {
|
||||
const displayValue = createSpan({ text: PluginSettings.defaultPageSize.toString() });
|
||||
slider.sliderEl.parentElement?.prepend(displayValue);
|
||||
const manualInput = createEl("input", { attr: { type: "number" }, cls: "github-link-slider-input" });
|
||||
manualInput.value = PluginSettings.defaultPageSize.toString();
|
||||
manualInput.addEventListener("change", (e) => {
|
||||
const value = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
PluginSettings.defaultPageSize = value;
|
||||
slider.setValue(value);
|
||||
void this.saveSettings();
|
||||
});
|
||||
slider.sliderEl.parentElement?.prepend(manualInput);
|
||||
slider.setLimits(0, 30, 1);
|
||||
slider.setDynamicTooltip();
|
||||
slider.setValue(PluginSettings.defaultPageSize);
|
||||
slider.onChange((value) => {
|
||||
displayValue.setText(value.toString());
|
||||
manualInput.value = value.toString();
|
||||
PluginSettings.defaultPageSize = value;
|
||||
this.saveSettings();
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show external link")
|
||||
.setDesc("When using a custom query, an 'Open on GitHub' link can be added to view the results there.")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(PluginSettings.showExternalLink);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.showExternalLink = value;
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show refresh button")
|
||||
.setDesc("Add a refresh button to tables to manually skip the cache.")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(PluginSettings.showRefresh);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.showRefresh = value;
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Show pagination")
|
||||
.setDesc("For query results with more than a single page of results, show pagination controls below the table.")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(PluginSettings.showPagination);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.showPagination = value;
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -102,7 +142,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
toggle.setValue(PluginSettings.tagTooltips);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.tagTooltips = value;
|
||||
this.saveSettings();
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -113,7 +153,29 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
toggle.setValue(PluginSettings.tagShowPRMergeable);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.tagShowPRMergeable = value;
|
||||
this.saveSettings();
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("File branch name")
|
||||
.setDesc("Append the branch name to links to an individual file inside a repo")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(PluginSettings.tagShowFileBranchName);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.tagShowFileBranchName = value;
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("File line number")
|
||||
.setDesc("Append the line number (if provided) to links to an individual file inside a repo")
|
||||
.addToggle((toggle) => {
|
||||
toggle.setValue(PluginSettings.tagShowFileLineNumber);
|
||||
toggle.onChange((value) => {
|
||||
PluginSettings.tagShowFileLineNumber = value;
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -136,14 +198,21 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
})
|
||||
.addSlider((slider) => {
|
||||
const displayValue = createSpan({ text: PluginSettings.cacheIntervalSeconds.toString() });
|
||||
slider.sliderEl.parentElement?.prepend(displayValue);
|
||||
const manualInput = createEl("input", { attr: { type: "number" }, cls: "github-link-slider-input" });
|
||||
manualInput.value = PluginSettings.cacheIntervalSeconds.toString();
|
||||
manualInput.addEventListener("change", (e) => {
|
||||
const value = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
PluginSettings.cacheIntervalSeconds = value;
|
||||
slider.setValue(value);
|
||||
void this.saveSettings();
|
||||
});
|
||||
slider.sliderEl.parentElement?.prepend(manualInput);
|
||||
slider.setValue(PluginSettings.cacheIntervalSeconds);
|
||||
slider.setLimits(10, 1200, 10);
|
||||
slider.setDynamicTooltip();
|
||||
slider.onChange(async (value) => {
|
||||
PluginSettings.cacheIntervalSeconds = value;
|
||||
displayValue.setText(value.toString());
|
||||
manualInput.value = value.toString();
|
||||
await this.saveSettings();
|
||||
this.plugin.setCacheInterval();
|
||||
});
|
||||
|
|
@ -164,14 +233,21 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
})
|
||||
.addSlider((slider) => {
|
||||
const displayValue = createSpan({ text: PluginSettings.maxCacheAgeHours.toString() });
|
||||
slider.sliderEl.parentElement?.prepend(displayValue);
|
||||
const manualInput = createEl("input", { attr: { type: "number" }, cls: "github-link-slider-input" });
|
||||
manualInput.value = PluginSettings.maxCacheAgeHours.toString();
|
||||
manualInput.addEventListener("change", (e) => {
|
||||
const value = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
PluginSettings.maxCacheAgeHours = value;
|
||||
slider.setValue(value);
|
||||
void this.saveSettings();
|
||||
});
|
||||
slider.sliderEl.parentElement?.prepend(manualInput);
|
||||
slider.setValue(PluginSettings.maxCacheAgeHours);
|
||||
slider.setLimits(0, 170, 10);
|
||||
slider.setDynamicTooltip();
|
||||
slider.onChange(async (value) => {
|
||||
PluginSettings.maxCacheAgeHours = value;
|
||||
displayValue.setText(value.toString());
|
||||
manualInput.value = value.toString();
|
||||
await this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
|
@ -192,14 +268,21 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
})
|
||||
.addSlider((slider) => {
|
||||
const displayValue = createSpan({ text: PluginSettings.minRequestSeconds.toString() });
|
||||
slider.sliderEl.parentElement?.prepend(displayValue);
|
||||
const manualInput = createEl("input", { attr: { type: "number" }, cls: "github-link-slider-input" });
|
||||
manualInput.value = PluginSettings.minRequestSeconds.toString();
|
||||
manualInput.addEventListener("change", (e) => {
|
||||
const value = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
PluginSettings.minRequestSeconds = value;
|
||||
slider.setValue(value);
|
||||
void this.saveSettings();
|
||||
});
|
||||
slider.sliderEl.parentElement?.prepend(manualInput);
|
||||
slider.setValue(PluginSettings.minRequestSeconds);
|
||||
slider.setLimits(10, 1200, 10);
|
||||
slider.setDynamicTooltip();
|
||||
slider.onChange(async (value) => {
|
||||
PluginSettings.minRequestSeconds = value;
|
||||
displayValue.setText(value.toString());
|
||||
manualInput.value = value.toString();
|
||||
await this.saveSettings();
|
||||
});
|
||||
});
|
||||
|
|
@ -241,7 +324,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
dropdown.setValue(PluginSettings.logLevel.toString());
|
||||
dropdown.onChange((value) => {
|
||||
PluginSettings.logLevel = Number(value);
|
||||
this.saveSettings();
|
||||
void this.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -250,6 +333,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
|
|||
const newData: GithubLinkPluginData = {
|
||||
cache: PluginData.cache,
|
||||
settings: PluginSettings,
|
||||
dataVersion: DATA_VERSION,
|
||||
};
|
||||
return this.plugin.saveData(newData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { LogLevel } from "src/logger";
|
||||
import { LogLevel } from "../logger";
|
||||
|
||||
export const DATA_VERSION = 1;
|
||||
|
||||
export interface GithubAccount {
|
||||
id: string;
|
||||
|
|
@ -12,15 +14,21 @@ export interface GithubAccount {
|
|||
export interface GithubLinkPluginData {
|
||||
settings: GithubLinkPluginSettings;
|
||||
cache: string[] | null;
|
||||
dataVersion: number;
|
||||
}
|
||||
|
||||
export interface GithubLinkPluginSettings {
|
||||
accounts: GithubAccount[];
|
||||
defaultAccount?: string;
|
||||
defaultPageSize: number;
|
||||
showPagination: boolean;
|
||||
showRefresh: boolean;
|
||||
showExternalLink: boolean;
|
||||
logLevel: LogLevel;
|
||||
tagTooltips: boolean;
|
||||
tagShowPRMergeable: boolean;
|
||||
tagShowFileBranchName: boolean;
|
||||
tagShowFileLineNumber: boolean;
|
||||
cacheIntervalSeconds: number;
|
||||
maxCacheAgeHours: number;
|
||||
minRequestSeconds: number;
|
||||
|
|
@ -29,9 +37,14 @@ export interface GithubLinkPluginSettings {
|
|||
export const DEFAULT_SETTINGS: GithubLinkPluginSettings = {
|
||||
accounts: [],
|
||||
defaultPageSize: 10,
|
||||
showPagination: true,
|
||||
showRefresh: true,
|
||||
showExternalLink: true,
|
||||
logLevel: LogLevel.Error,
|
||||
tagTooltips: false,
|
||||
tagShowPRMergeable: false,
|
||||
tagShowFileBranchName: true,
|
||||
tagShowFileLineNumber: true,
|
||||
cacheIntervalSeconds: 60,
|
||||
maxCacheAgeHours: 120,
|
||||
minRequestSeconds: 60,
|
||||
|
|
|
|||
64
src/util.ts
64
src/util.ts
|
|
@ -1,7 +1,16 @@
|
|||
import { isEqual as lodashIsEqual } from "lodash";
|
||||
|
||||
export type RemoveIndexSignature<T> = {
|
||||
[K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* Small wrapper around lodash isEqual to tell typescript to stop complaining about its any types
|
||||
*/
|
||||
export function isEqual<T, R>(value: T, other: R): boolean {
|
||||
return lodashIsEqual(value, other);
|
||||
}
|
||||
|
||||
export function titleCase(value: string): string {
|
||||
const words = value.split(/[-_]/);
|
||||
return words.map((w) => w.charAt(0)?.toUpperCase() + w.slice(1)).join(" ");
|
||||
|
|
@ -17,6 +26,55 @@ export function assertDefined<T>(value: T | undefined | null): asserts value is
|
|||
}
|
||||
}
|
||||
|
||||
type SourceToTargetFunction<T, R, K extends keyof R> = (value: T) => R[K];
|
||||
type SourceToTarget<T, R, K extends keyof R> = K extends keyof T
|
||||
? T[K] extends R[K]
|
||||
? true | SourceToTargetFunction<T, R, K>
|
||||
: SourceToTargetFunction<T, R, K>
|
||||
: SourceToTargetFunction<T, R, K>;
|
||||
type ObjectMapConfig<T, R> = { [K in keyof R]: SourceToTarget<T, R, K> };
|
||||
|
||||
/**
|
||||
* Create an object of type R from source object of type T
|
||||
* @param config An object describing how to map the values from the source to the new object.
|
||||
* Using true for a key passes the value from the source object as-is (if possible),
|
||||
* while a function will be called to transform the property. Config must include
|
||||
* every non-optional key on the target object type.
|
||||
*/
|
||||
export function mapObject<T, R>(
|
||||
value: T,
|
||||
config: ObjectMapConfig<T, R>,
|
||||
removeUndefined = false,
|
||||
removeNull = false,
|
||||
): R {
|
||||
const resultEntries: [string, unknown][] = [];
|
||||
|
||||
for (const entry of Object.entries(config)) {
|
||||
const key = entry[0];
|
||||
const configSet = entry[1] as SourceToTarget<T, R, keyof R>;
|
||||
|
||||
if (configSet === true) {
|
||||
resultEntries.push([key, value[key as keyof T]]);
|
||||
} else {
|
||||
resultEntries.push([key, configSet(value)]);
|
||||
}
|
||||
}
|
||||
|
||||
let finalEntries: [string, unknown][] = [];
|
||||
if (removeUndefined || removeNull) {
|
||||
for (const [key, value] of resultEntries) {
|
||||
if ((removeUndefined && value === undefined) || (removeNull && value === null)) {
|
||||
continue;
|
||||
}
|
||||
finalEntries.push([key, value]);
|
||||
}
|
||||
} else {
|
||||
finalEntries = [...resultEntries];
|
||||
}
|
||||
|
||||
return Object.fromEntries(finalEntries) as R;
|
||||
}
|
||||
|
||||
export function sanitizeObject<T>(params: T, usableFieldMap: Record<keyof T, boolean>): T {
|
||||
const usableFields: (keyof T)[] = Object.entries(usableFieldMap)
|
||||
.filter(([_, value]) => value)
|
||||
|
|
@ -57,6 +115,7 @@ export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>)
|
|||
// Handle parsing with try / catch
|
||||
let parsed: T;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
parsed = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return null;
|
||||
|
|
@ -68,6 +127,7 @@ export function safeJSONParse<T>(value: string, props: Record<keyof T, boolean>)
|
|||
for (const [_prop, include] of Object.entries(props)) {
|
||||
const prop = _prop as keyof T;
|
||||
if (include && parsed[prop]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
result[prop] = parsed[prop];
|
||||
}
|
||||
}
|
||||
|
|
@ -128,9 +188,9 @@ export class RequestError implements Error {
|
|||
this.message = originalError.message;
|
||||
|
||||
// Request props
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
this.headers = (originalError as any).headers;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
this.status = (originalError as any).status;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
styles.css
81
styles.css
|
|
@ -62,6 +62,7 @@ body.theme-light {
|
|||
--gh-color-primer-border-contrast: rgba(31, 35, 40, 0.1);
|
||||
--gh-color-primer-shadow-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
--gh-color-primer-shadow-inset: inset 0 1px 0 rgba(208, 215, 222, 0.2);
|
||||
--status-color: var(--gh-color-canvas-subtle);
|
||||
}
|
||||
|
||||
body.theme-dark {
|
||||
|
|
@ -150,6 +151,10 @@ body.theme-dark {
|
|||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.github-link-slider-input {
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.github-link-inline {
|
||||
display: inline-flex;
|
||||
background-color: var(--gh-color-canvas-default);
|
||||
|
|
@ -175,6 +180,14 @@ body.theme-dark {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.github-link-inline-section:first-child {
|
||||
border-radius: 3.5px 0 0 3.5px;
|
||||
}
|
||||
|
||||
.github-link-inline-section:last-child {
|
||||
border-radius: 0 3.5px 3.5px 0;
|
||||
}
|
||||
|
||||
.github-link-inline-icon {
|
||||
display: inline-flex;
|
||||
padding: 2px 0;
|
||||
|
|
@ -245,6 +258,11 @@ body.theme-dark {
|
|||
background-color: var(--gh-color-danger-subtle);
|
||||
border-color: var(--gh-color-danger-emphasis);
|
||||
}
|
||||
.github-link-inline-section:has(> .github-link-inline-error-icon) {
|
||||
color: var(--gh-color-closed-fg);
|
||||
background-color: var(--gh-color-danger-subtle);
|
||||
border-color: var(--gh-color-danger-emphasis);
|
||||
}
|
||||
|
||||
.github-link-inline-section:has(> .github-link-inline-repo) {
|
||||
background-color: var(--gh-color-accent-subtle);
|
||||
|
|
@ -259,12 +277,27 @@ body.theme-dark {
|
|||
}
|
||||
|
||||
.github-link-inline-pr-title,
|
||||
.github-link-inline-issue-title {
|
||||
.github-link-inline-issue-title,
|
||||
.github-link-inline-issue-title-section,
|
||||
.github-link-inline-file {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-wrap: nowrap;
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
.github-link-inline-issue-number {
|
||||
color: var(--gh-color-fg-muted);
|
||||
}
|
||||
|
||||
.github-link-inline-file,
|
||||
.github-link-inline-issue-title-section {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.github-link-inline-file > .github-link-inline-file-line-number,
|
||||
.github-link-inline-file > .github-link-inline-file-branch {
|
||||
color: var(--gh-color-fg-muted);
|
||||
}
|
||||
|
||||
.github-link-table-wrapper {
|
||||
margin-block-end: var(--p-spacing);
|
||||
|
|
@ -276,21 +309,22 @@ body.theme-dark {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.github-link-table-refresh {
|
||||
.github-link-table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.github-link-table-refresh:has(a) {
|
||||
justify-content: space-between;
|
||||
.github-link-table-pagination {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.github-link-table-refresh-external-link {
|
||||
.github-link-table-footer-external-link {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.github-link-table-refresh > button > svg {
|
||||
.github-link-table-footer > button > svg {
|
||||
width: var(--icon-s);
|
||||
height: var(--icon-s);
|
||||
stroke-width: var(--icon-s-stroke-width);
|
||||
|
|
@ -304,6 +338,10 @@ table.github-link-table {
|
|||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.github-link-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.github-link-table a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -317,8 +355,35 @@ table.github-link-table {
|
|||
}
|
||||
|
||||
.github-link-table-status,
|
||||
.github-link-table-author {
|
||||
.github-link-table-author,
|
||||
.github-link-table-labels {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.github-link-table-author {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.github-link-table-labels {
|
||||
height: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.github-link-table-label {
|
||||
border-radius: 2em;
|
||||
font-size: 0.8em;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--status-color);
|
||||
}
|
||||
|
||||
body.theme-light .github-link-table .github-link-table-labels .github-link-table-label {
|
||||
background-color: color-mix(in srgb, var(--status-color) 50%, transparent);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
body.theme-dark .github-link-table .github-link-table-labels .github-link-table-label {
|
||||
background-color: color-mix(in srgb, var(--status-color) 20%, transparent);
|
||||
color: var(--status-color);
|
||||
}
|
||||
|
|
|
|||
66
test/element.ts
Normal file
66
test/element.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Local version of obsidian's 'createEl' function, copied from
|
||||
* https://ryotaushio.github.io/the-hobbyist-dev/obsidian/api-&-internals/createel.html
|
||||
*/
|
||||
function createEl<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
options?: string | DomElementInfo,
|
||||
callback?: (el: HTMLElementTagNameMap[K]) => void,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
if (typeof options === "string") options = { cls: options };
|
||||
options = options || {};
|
||||
|
||||
if (options.cls) Array.isArray(options.cls) ? (el.className = options.cls.join(" ")) : (el.className = options.cls);
|
||||
if (options.text) el.setText(options.text);
|
||||
if (options.attr) {
|
||||
for (const [k, v] of Object.entries(options.attr)) {
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
}
|
||||
if (options.title !== undefined) el.title = options.title;
|
||||
if (
|
||||
options.value !== undefined &&
|
||||
(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLOptionElement)
|
||||
) {
|
||||
el.value = options.value;
|
||||
}
|
||||
if (options.type && el instanceof HTMLInputElement) el.type = options.type;
|
||||
if (options.type && el instanceof HTMLStyleElement) el.setAttribute("type", options.type);
|
||||
if (options.placeholder && el instanceof HTMLInputElement) el.placeholder = options.placeholder;
|
||||
if (
|
||||
options.href &&
|
||||
(el instanceof HTMLAnchorElement || el instanceof HTMLLinkElement || el instanceof HTMLBaseElement)
|
||||
)
|
||||
el.href = options.href;
|
||||
callback?.(el);
|
||||
if (options.parent) {
|
||||
if (options.prepend) options.parent.insertBefore(el, options.parent.firstChild);
|
||||
else options.parent.appendChild(el);
|
||||
}
|
||||
// add event listeners (e.g. { onclick: () => {} })
|
||||
for (const key in options) {
|
||||
if (Object.prototype.hasOwnProperty.call(options, key) && key.startsWith("on")) {
|
||||
const value = options[key as keyof DomElementInfo];
|
||||
if (typeof value === "function") el.addEventListener(key.substring(2), value);
|
||||
}
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function createSpan(options?: string | DomElementInfo, callback?: (el: HTMLSpanElement) => void) {
|
||||
return createEl("span", options, callback);
|
||||
}
|
||||
|
||||
function createDiv(options?: string | DomElementInfo, callback?: (el: HTMLDivElement) => void) {
|
||||
return createEl("div", options, callback);
|
||||
}
|
||||
|
||||
export function elementTestSetup() {
|
||||
HTMLElement.prototype.setText = function (text: string) {
|
||||
this.innerText = text;
|
||||
};
|
||||
window.createEl = createEl;
|
||||
window.createSpan = createSpan;
|
||||
window.createDiv = createDiv;
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"target": "ES2018",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
|
|
@ -12,7 +11,8 @@
|
|||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"]
|
||||
"lib": ["DOM", "ES5", "ES6", "ES7"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"1.2.0": "0.15.0",
|
||||
"1.1.0": "0.15.0",
|
||||
"1.0.3": "0.15.0",
|
||||
"1.0.2": "0.15.0",
|
||||
"1.0.0": "0.15.0",
|
||||
"0.7.2": "0.15.0",
|
||||
"0.7.1": "0.15.0",
|
||||
"0.7.0": "0.15.0",
|
||||
"0.6.0": "0.15.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue