Compare commits

..

130 commits
0.6.0 ... main

Author SHA1 Message Date
Nathan
a5ad979881
Update LICENSE 2025-08-04 14:29:41 -05:00
Nathan Smith
4366eebfb6 🚀 release: Release v1.2.0 2025-03-08 18:25:57 -06:00
Nathan
0f809ae0a9
Merge pull request #170 from nathonius/feat/158/issue-number
 feat: #158 Always display issue number
2025-03-08 18:22:50 -06:00
Nathan Smith
2d9dba0d43 feat: #158 Always display issue number
Closes: #158
2025-03-08 15:49:05 -06:00
Nathan
568b4d4335
Merge pull request #168 from nathonius/release/1.1.0
🚀 release: Release v1.1.0
2025-02-26 23:38:44 -06:00
Nathan Smith
37ff39dbc5 🚀 release: Release v1.1.0 2025-02-26 23:36:51 -06:00
Nathan
ceab053dca
Merge pull request #167 from nathonius/feat/155/file-tag
 feat: #155 Show the full filename when linking directly to a file
2025-02-26 23:30:45 -06:00
Nathan Smith
dd3bfaa15f 🚨 test: #155 Add file sections to tests 2025-02-26 23:25:55 -06:00
Nathan Smith
1b4317c3b9 feat: #155 Show the full filename when linking directly to a file
Closes: #155
2025-02-26 23:23:21 -06:00
Nathan
3289da5a1d
Merge pull request #166 from nathonius/feat/154/sliders
 feat: #154 Add manual inputs for all slider settings
2025-02-26 22:50:08 -06:00
Nathan Smith
69f2d2a08e feat: #154 Add manual inputs for all slider settings
Closes: #154
2025-02-26 22:47:35 -06:00
Nathan Smith
a4d1cbc7bf 🚨 test: Add a test for a github.io page repo 2025-02-26 22:23:48 -06:00
Nathan
fef1eb84a0
Merge pull request #163 from nathonius/more-tests
More tests
2025-02-26 22:09:45 -06:00
Nathan Smith
6e143d00ce 🚨 test: Add more tests 2025-01-22 23:06:14 -05:00
Nathan Smith
ebb30a8940 🔨 refactor: Adjust test methodology 2025-01-22 00:17:37 -05:00
Nathan
020fcead39
Merge pull request #162 from nathonius/all-contributors/add-tmcw
docs: add tmcw as a contributor for code
2025-01-22 00:10:35 -05:00
allcontributors[bot]
088597bb8c
docs: update .all-contributorsrc 2025-01-22 05:08:25 +00:00
allcontributors[bot]
ed4bc6961f
docs: update README.md 2025-01-22 05:08:24 +00:00
Nathan
0878512754
Merge pull request #151 from tmcw/improve-inline-link-style
Improve inline link style
2025-01-22 00:07:32 -05:00
Nathan
82f5dadf83
Merge pull request #161 from nathonius/changelog-fix
🧹 chore: correct changelog link
2025-01-19 19:09:19 -05:00
Nathan Smith
353b95ec34 🧹 chore: correct changelog link 2025-01-19 19:07:43 -05:00
Nathan
cd7ebaf679
Merge pull request #160 from nathonius/all-contributors/add-joshleaves
docs: add joshleaves as a contributor for code, and test
2025-01-19 19:06:13 -05:00
allcontributors[bot]
84eeedb9ba
docs: update .all-contributorsrc 2025-01-20 00:04:25 +00:00
allcontributors[bot]
cae9718a6e
docs: update README.md 2025-01-20 00:04:24 +00:00
Nathan
640a042946
Merge pull request #159 from nathonius/release/1.0.3
🚀 release: Release v1.0.3
2025-01-19 19:01:11 -05:00
Nathan Smith
05cbb3dce2 🚀 release: Release v1.0.3 2025-01-19 18:59:34 -05:00
Nathan
9cdfba024d
Merge pull request #157 from joshleaves/fix/156-linking-to-file
Fix: Match links to a single file with their dot extensions
2025-01-19 18:53:27 -05:00
Nathan Smith
41f6c40bb3 remove unnecessary escapes 2025-01-19 18:50:40 -05:00
red
7679a149be Fix: Add more edge cases in tests and rework regexp 2025-01-18 02:22:51 +01:00
red
bf0572256a Fix: Don't capture first word after repo 2025-01-04 23:05:34 +01:00
red
0257f4909f Fix: Simplified Regexp 2024-12-30 21:01:12 +01:00
red
ea163a31e0 Fix: More URL subtleties, and some unit tests 2024-12-30 20:49:05 +01:00
red
42d35e4c70 Fix: Match links to a single file with their dot extensions 2024-12-20 09:48:00 +01:00
Tom MacWright
792ca07395
Improve inline link style 2024-09-09 17:32:46 -04:00
Nathan
10aabb2a34
Merge pull request #149 from nathonius/release/1.0.2
🚀 release: Release v1.0.2
2024-08-30 18:34:20 -04:00
Nathan Smith
b355284cf5 🚀 release: Release v1.0.2
Includes changes from 1.0.1 which was improperly released.
2024-08-30 18:33:05 -04:00
Nathan
be2aa15f32
Merge pull request #148 from nathonius/fix/142/oauth-404
🐛 fix: #142 Trim oauth client id of whitespace
2024-08-30 18:27:14 -04:00
Nathan Smith
0b38bd17f0 🐛 fix: #142 Trim oauth client id of whitespace
Was resulting in 404

Closes: #142
2024-08-30 17:42:49 -04:00
Nathan
7aa0c14e01
Merge pull request #147 from nathonius/fix/143/use-custom-oauth
🐛 fix: #143 Use custom oauth setting during token gen for new account
2024-08-30 17:35:11 -04:00
Nathan Smith
061b8bdab9 🐛 fix: #143 Use custom oauth setting during token gen for new account
Closes: #143
2024-08-30 17:33:38 -04:00
Nathan
4919fe23eb
Merge pull request #146 from nathonius/fix/144/tolerant-url
🐛 fix: #144 Tolerate badly formatted URLs
2024-08-30 17:25:28 -04:00
Nathan Smith
2d42cb3edf 🐛 fix: #144 Tolerate badly formatted URLs
URL constructor should not throw error anymore. Known special paths are ignored.

Closes: #144
2024-08-30 17:17:34 -04:00
Nathan
8d495d990d
Merge pull request #141 from nathonius/fix/139/display-text-links
🐛 fix: #139 Adjust url regex to exclude URLs with display text
2024-08-19 11:33:35 -04:00
Nathan Smith
0fb4e4dbd0 🐛 fix: #139 Adjust url regex to exclude URLs with display text
Closes: #139
2024-08-16 21:27:09 -04:00
Nathan
43728122a6
Merge pull request #138 from nathonius/release/v1.0.0
🚀 release: Release v1.0.0
2024-07-08 18:53:12 -04:00
Nathan Smith
9d495329d6 🚀 release: Release v1.0.0 2024-07-08 18:51:49 -04:00
Nathan
97b1f8a34f
Merge pull request #137 from nathonius/docs/adjust-badge-spacing
📚 docs: Adjust badge spacing
2024-07-08 18:29:43 -04:00
Nathan Smith
cd0f34086c 🧹 chore: Try to fix code contrib 2024-07-08 18:28:41 -04:00
Nathan Smith
9b7997cc30 📚 docs: Adjust badge spacing 2024-07-08 18:22:17 -04:00
Nathan
12a0b6e332
Merge pull request #136 from nathonius/all-contributors/add-nikclayton
docs: add nikclayton as a contributor for bug
2024-07-08 18:20:13 -04:00
allcontributors[bot]
376174907a
docs: update .all-contributorsrc 2024-07-08 22:17:51 +00:00
allcontributors[bot]
968dfbcc8a
docs: update README.md 2024-07-08 22:17:50 +00:00
Nathan
309177e07a
Merge pull request #134 from nathonius/all-contributors/add-agriffis
docs: add agriffis as a contributor for code
2024-07-08 18:15:04 -04:00
allcontributors[bot]
613c53b96b
docs: update .all-contributorsrc 2024-07-08 22:13:39 +00:00
allcontributors[bot]
1f43fba5dd
docs: update README.md 2024-07-08 22:13:38 +00:00
Nathan
5aab216db0
Merge pull request #133 from nathonius/all-contributors/add-nathonius
docs: add nathonius as a contributor for doc
2024-07-08 18:12:31 -04:00
allcontributors[bot]
2b270db569
docs: update .all-contributorsrc 2024-07-08 22:11:09 +00:00
allcontributors[bot]
5a9766bf30
docs: update README.md 2024-07-08 22:11:08 +00:00
Nathan
426078dc2e
Merge pull request #132 from nathonius/doc/93/readme-revamp
doc/93/readme revamp
2024-07-08 18:08:29 -04:00
Nathan
5c106a9f1e
Merge pull request #131 from nathonius/fix/125/user-column-overflow
🐛 fix: #125 Set author and assignee columns to show full width
2024-07-08 18:07:31 -04:00
Nathan Smith
aad1320bd8 📚 docs: #93 Update readme with clearer desc, doc links, and contrib
Closes: #93
2024-07-08 18:07:06 -04:00
Nathan Smith
85093e223a 🐛 fix: #125 Set author and assignee columns to show full width
Closes: #125
2024-07-08 17:49:12 -04:00
Nathan
3b98818272
Merge pull request #130 from nathonius/fix/121/codeBlock
🐛 fix: #121 Rewrite live preview renderer
2024-07-07 23:19:55 -04:00
Nathan Smith
982ac30a89 🐛 fix: #121 Prevent rendering inside inline code block 2024-07-07 23:18:42 -04:00
Nathan Smith
f2eee1f178 🐛 fix: #121 Rewrite live preview renderer 2024-07-07 23:11:14 -04:00
Nathan
9b59e6abc6
Merge pull request #129 from nathonius/feat/114/org-issues
 feat: #114 Support org issues query
2024-07-07 09:38:03 -04:00
Nathan Smith
4e5d4f07d5 🚧 build: #114 Correct tsconfig target
Was already es2018 in esbuild config
2024-07-07 09:34:52 -04:00
Nathan Smith
5f65a14c24 🔨 refactor: #114 Extract parameter transformation 2024-07-07 09:34:16 -04:00
Nathan Smith
a9ff3857c8 feat: #114 Support org issues query
Closes: #114
2024-06-30 18:32:18 -04:00
Nathan
5640f400ea
Merge pull request #128 from nathonius/migrate
🐛 fix: Add migration mechanism for plugin data
2024-06-30 16:51:59 -04:00
Nathan Smith
d0aff85157 🐛 fix: Add migration mechanism for plugin data 2024-06-30 16:50:30 -04:00
Nathan
50f2e7b14a
Merge pull request #127 from nathonius/feat/126/external-link
 feat: #126 Add toggle for external link
2024-06-30 16:33:54 -04:00
Nathan Smith
4cad2f634f feat: #126 Add toggle for external link
Closes: #126
2024-06-30 16:23:32 -04:00
Nathan
d0496b1600
Merge pull request #124 from nathonius/feat/123/assignee
 feat: #123 Add assignee column
2024-06-30 16:14:03 -04:00
Nathan Smith
f5eca6a566 feat: #123 Add assignee column
Closes: #123
2024-06-30 16:10:28 -04:00
Nathan
99af75cffe
Merge pull request #122 from nathonius/feat/119/label-column
 feat: #119 Add Labels column
2024-06-30 16:01:32 -04:00
Nathan Smith
2af4f73d51 feat: #119 Add Labels column
Closes: #119
2024-06-30 15:59:28 -04:00
Nathan
cb64714466
Merge pull request #120 from nathonius/feat/102/table-pagination
Feat/102/table pagination
2024-06-30 14:41:55 -04:00
Nathan Smith
5327ed61ef 🔨 refactor: #102 Sonar linting 2024-06-30 14:38:53 -04:00
Nathan Smith
1c6d1a28ad 🚨 test: #102 Add header property to cache test 2024-06-30 14:35:36 -04:00
Nathan Smith
9e53b68bc5 🚨 test: #102 Correct test imports 2024-06-30 14:33:38 -04:00
Nathan Smith
e5aaebc1bb feat: #102 Add refresh and pagination settings 2024-06-30 13:09:46 -04:00
Nathan Smith
b7ac55dc59 feat: #102 ensure cache is applied but can be skipped 2024-06-30 13:06:39 -04:00
Nathan Smith
6f2a97ebe1 feat: #102 Ensure table query responses are cached and paginated 2024-06-30 12:54:44 -04:00
Nathan Smith
f204ddd71d feat: #102 reimplement external link 2024-06-30 12:40:21 -04:00
Nathan Smith
0d11e02991 🔨 refactor: #102 Move sort utilities to their own file 2024-06-30 12:37:31 -04:00
Nathan Smith
80bb501282 feat: #102 Move table refresher to plugin setting 2024-06-30 12:35:25 -04:00
Nathan Smith
0aeff6cd46 🔨 refactor: #102 Clean up query class 2024-06-30 12:33:08 -04:00
Nathan Smith
a1724f2ca2 🔨 refactor: #102 Make imports more consistent 2024-06-30 12:11:33 -04:00
Nathan Smith
f670708c68 🔨 refactor: #102 Remove init logic from query constructor 2024-06-29 16:06:55 -04:00
Nathan Smith
c2cc1dea9c Merge branch 'main' into feat/102/table-pagination 2024-06-29 16:04:00 -04:00
Nathan Smith
d717a93976 feat: #102 Improve spacing of pagination links 2024-06-28 22:45:18 -04:00
Nathan Smith
dd32f7299f feat: #102 Only show first/last links not equal prev/next 2024-06-28 22:40:52 -04:00
Nathan Smith
247997c431 feat: #102 Working pagination 2024-06-28 22:33:13 -04:00
Nathan
ea2ee0608c
Merge pull request #117 from nathonius/release/0.7.2
🚀 release: Release v0.7.2
2024-06-16 14:43:54 -04:00
Nathan Smith
1a3059512a 🚀 release: Release v0.7.2 2024-06-16 14:41:37 -04:00
Nathan
65ab8c207e
Merge pull request #116 from nathonius/fix/115/popout-window
🐛 fix: #115 Ensure popout windows arent closed when clicking links
2024-06-16 14:19:30 -04:00
Nathan Smith
38091a52e8 🐛 fix: #115 Ensure popout windows arent closed when clicking links
Also added the actual url to an authors page when rendering it in the table.

Closes: #115
2024-06-16 14:16:19 -04:00
Nathan Smith
6be7a0131d types are hard 2024-06-01 01:38:38 -04:00
Nathan
4ca024edf4
Merge pull request #112 from nathonius/test/107/jest
Unit tests
2024-04-23 00:03:34 -04:00
Nathan Smith
8ef8163425 🤖 ci: #107 Add test to code check workflow 2024-04-23 00:00:21 -04:00
Nathan Smith
fae232cba4 🚨 test: #107 Add some general sanity tests for the setting up the plugin
Closes: #107
2024-04-22 23:53:55 -04:00
Nathan Smith
7584ec8470 🚨 test: #107 Working simple test 2024-04-22 13:59:17 -04:00
Nathan Smith
8cfc5690f1 🚨 test: #107 Set up jest 2024-04-21 23:31:50 -04:00
Nathan
7aa5aee6ab
Merge pull request #111 from nathonius/ci/109/eslint
Upgrade eslint
2024-04-21 22:06:22 -04:00
Nathan Smith
5cc35c318d 🔨 refactor: #109 Remove unused void operator 2024-04-21 22:05:11 -04:00
Nathan Smith
2600878b0b 🔨 refactor: #109 Resolve new eslint errors
Closes: #109
2024-04-21 21:59:58 -04:00
Nathan Smith
810a15646e 🤖 ci: #109 Upgrade to flat eslint config 2024-04-21 21:46:33 -04:00
Nathan
0a15cd53a3
Merge pull request #108 from nathonius/feat/85/refresh-errored-tags
Show errored tags with some info
2024-04-21 21:13:42 -04:00
Nathan Smith
afe8a2ed0c 🔨 refactor: #85 Move queue to dependencies 2024-04-21 21:08:06 -04:00
Nathan
14ba964f6c
Merge pull request #106 from nathonius/release/v0.7.1
🚀 release: Release v0.7.1
2024-04-21 09:23:07 -04:00
Nathan Smith
d6db5dadbb 🚀 release: Release v0.7.1 2024-04-21 09:22:15 -04:00
Nathan
a919786982
Merge pull request #105 from agriffis/main
fix: #104 init data to empty object
2024-04-21 09:17:25 -04:00
Aron Griffis
d5b48c7cac fix: #104 init data to empty object 2024-04-21 09:02:38 -04:00
Nathan Smith
ddd76872c0 feat: #85 Add error icon to tags that could not be retrieved 2024-04-16 22:34:16 -04:00
Nathan
f8c9e4794e
Merge pull request #100 from nathonius/release/0.7.0
release/0.7.0
2024-04-11 23:00:24 -04:00
Nathan Smith
0bbd9601f0 🚀 release: Release v0.7.0 2024-04-11 22:57:30 -04:00
Nathan Smith
d2908c72d0 🤖 ci: Update version bump script to take an arg and update everywhere 2024-04-11 22:54:19 -04:00
Nathan
240189c961
Merge pull request #99 from nathonius/feat/84/oauth
feat/84/oauth
2024-04-11 22:41:02 -04:00
Nathan Smith
62a6f0d94e feat: #84 Support custom oauth app
Closes: #84
2024-04-11 22:37:59 -04:00
Nathan Smith
807d604a1d 🐛 fix: #84 Correct cache save 2024-04-06 13:55:34 -04:00
Nathan Smith
40aff6125a 🐛 fix: #84 Trim extra spaces from orgs 2024-04-06 13:35:28 -04:00
Nathan Smith
144013cee9 🔨 refactor: #84 Move account settings into new class 2024-04-06 13:31:27 -04:00
Nathan
01646824d1
Merge pull request #97 from nathonius/doc/better-commits
📚 docs: Add note about better-commits to contrib
2024-04-06 00:00:55 -04:00
Nathan Smith
b45b970aea 📚 docs: Add note about better-commits to contrib 2024-04-05 23:59:57 -04:00
Nathan
925767880e
Merge pull request #95 from nathonius/feat/72/check-status
 feat: #72 Add simple PR check status icons
2024-04-05 23:56:35 -04:00
Nathan Smith
eccc0ef3e0 feat: #72 Add simple PR check status icons
Can be more granular in the future

Closes: #72
2024-04-05 23:54:06 -04:00
Nathan
8719cb1ea6
Merge pull request #94 from nathonius/feat/80/org-inline
 feat: #80 Show user or org name when transforming link to repo
2024-04-05 22:05:14 -04:00
Nathan Smith
312226eedd 🔨 refactor: #80 Reduce complexity of create tag function 2024-04-05 22:04:10 -04:00
Nathan Smith
9237b2f0fa feat: #80 Show user or org name when transforming link to repo
Closes: #80
2024-04-05 21:47:55 -04:00
60 changed files with 8327 additions and 1112 deletions

59
.all-contributorsrc Normal file
View 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
View 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

View file

@ -1,3 +0,0 @@
node_modules/
main.js

View file

@ -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": "^_"
}
]
}
}

View file

@ -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
View file

@ -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/

View file

@ -16,6 +16,72 @@ 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
- ⚡ Plugin data is initialized if no data is present on plugin load. Thanks @agriffis!
## [0.7.0] - 2024-04-11
### Changed
- If a link is just a repo, the username or organization will be included as well
- Links to PRs will show a simple mergeable or not mergeable PR status
- Added support for custom oauth app. See [the wiki](https://github.com/nathonius/obsidian-github-link/wiki/Authentication#custom-oauth-app) for more info!
## [0.6.0] - 2024-03-29
### Changed
@ -29,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
@ -89,6 +154,14 @@ 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
[0.5.1]: https://github.com/nathonius/obsidian-github-link/compare/0.5.0...0.5.1
[0.5.0]: https://github.com/nathonius/obsidian-github-link/compare/0.4.0...0.5.0

View file

@ -17,4 +17,6 @@ Code must meet the following standards:
- Conforms to the code style enforced by prettier
- Pass linting using the project's ESLint config (automated as a PR check)
- Pass a static code analysis scan by SonarCloud (automated as a PR check)
- Review by a project maintainer
- Review by a project maintainer
It is also recommended (but not strictly required) to use [better-commits](https://github.com/Everduin94/better-commits) to generate commit messages with the correct structure and emoji.

42
LICENSE
View file

@ -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.

View file

@ -1,11 +1,14 @@
# GitHub Link
[![Wiki Badge](https://img.shields.io/badge/wiki-documentation-blue?logo=github)](https://github.com/nathonius/obsidian-github-link/wiki)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nathonius_obsidian-github-link&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nathonius_obsidian-github-link)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nathonius_obsidian-github-link&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nathonius_obsidian-github-link) [![All Contributors](https://img.shields.io/github/all-contributors/nathonius/obsidian-github-link?color=ee8449)](#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.
![ExampleTable](doc/ExampleQueryResult.png)
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
View 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
View 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.");
}
}

View 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.");
}
}

View 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.");
}
}

View 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.");
}
}

View 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.");
}
}

View 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
View 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
View 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
View 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;

View file

@ -1,2 +1,2 @@
import { GithubLinkPlugin } from "src/plugin";
import { GithubLinkPlugin } from "./src/plugin";
export default GithubLinkPlugin;

View file

@ -1,10 +1,10 @@
{
"id": "github-link",
"name": "GitHub Link",
"version": "0.6.0",
"version": "1.2.0",
"minAppVersion": "0.15.0",
"description": "Enrich your notes with issue and pull request content from GitHub",
"author": "Nathonius",
"authorUrl": "https://nathan-smith.org/",
"isDesktopOnly": false
}
}

5444
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,14 @@
{
"name": "obsidian-github-link",
"version": "0.6.0",
"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"
}
}
}

View file

@ -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);
}
}

View file

@ -1,20 +1,24 @@
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,
IssueListParams,
IssueListResponse,
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> };
@ -24,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(
@ -56,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(
@ -77,33 +152,61 @@ 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(
org: string,
repo: string,
ref: string,
token?: string,
skipCache = false,
): Promise<CheckRunListResponse> {
const { response } = await this.githubRequest(
{ url: `${GitHubApi.baseApi}/${org}/${repo}/commits/${ref}/check-runs` },
token,
skipCache,
);
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()}`,
@ -115,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);
@ -136,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);
}
@ -159,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));
}
}
@ -204,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

View file

@ -4,6 +4,8 @@ import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { request } from "@octokit/request";
import { requestUrl } from "obsidian";
const defaultClientId = "baf0370cb98e1387d244";
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
if (!headers) {
return undefined;
@ -71,10 +73,10 @@ async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined
return partialResult as Response;
}
export const auth = (verificationHandler: OnVerificationCallback) =>
export const auth = (verificationHandler: OnVerificationCallback, clientId: string = defaultClientId) =>
createOAuthDeviceAuth({
clientType: "oauth-app",
clientId: "baf0370cb98e1387d244",
clientId,
scopes: ["repo"],
onVerification: verificationHandler,
request: request.defaults({

View file

@ -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;
}
}
}

View file

@ -1,24 +1,27 @@
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,
IssueListResponse,
IssueResponse,
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();
@ -44,129 +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,
});
export function getPullRequest(
org: string,
repo: string,
pullRequest: number,
skipCache = false,
): Promise<PullResponse> {
return api.getPullRequest(org, repo, pullRequest, getToken(org), skipCache);
}
setPageSize(_params);
return api.listPullRequestsForRepo(org, repo, _params, 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) {
@ -175,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;
@ -190,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;
}

View file

@ -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"];
@ -23,6 +41,9 @@ export type CodeResponse = RestEndpointMethodTypes["repos"]["getContent"]["respo
export type RepoSearchResponse = RestEndpointMethodTypes["search"]["repos"]["response"]["data"];
export type TimelineCrossReferencedEvent = OpenAPI.components["schemas"]["timeline-cross-referenced-event"];
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"];
@ -51,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") {

View file

@ -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
View 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;
}

View file

@ -14,6 +14,16 @@ const PRStatusText: Readonly<Record<IssueStatus, string>> = {
[IssueStatus.Done]: "Pull request merged",
};
const PRMergeableIcon: Readonly<Record<`${boolean}`, string>> = {
true: "lucide-check",
false: "lucide-x",
};
const PRMergeableText: Readonly<Record<`${boolean}`, string>> = {
true: "Mergeable",
false: "Not mergeable",
};
const IssueIcon: Readonly<Record<IssueStatus, string>> = {
[IssueStatus.Open]: "lucide-circle-dot",
[IssueStatus.Closed]: "lucide-circle-slash",
@ -26,7 +36,7 @@ const IssueStatusText: Readonly<Record<IssueStatus, string>> = {
[IssueStatus.Done]: "Issue closed as completed",
};
export function setPRIcon(icon: HTMLElement, status: IssueStatus) {
export function setPRIcon(icon: HTMLElement, status: IssueStatus): void {
if (PluginSettings.tagTooltips) {
icon.setAttribute("aria-label", PRStatusText[status]);
}
@ -36,7 +46,7 @@ export function setPRIcon(icon: HTMLElement, status: IssueStatus) {
icon.dataset.status = status;
}
export function setIssueIcon(icon: HTMLElement, status: IssueStatus) {
export function setIssueIcon(icon: HTMLElement, status: IssueStatus): void {
if (PluginSettings.tagTooltips) {
icon.setAttribute("aria-label", IssueStatusText[status]);
}
@ -45,3 +55,17 @@ export function setIssueIcon(icon: HTMLElement, status: IssueStatus) {
icon.classList.add("issue");
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}`]);
}
setIcon(icon, PRMergeableIcon[`${mergeable}`]);
icon.classList.add("pull-request-mergeable");
icon.dataset.status = `${mergeable}`;
}

46
src/inline/inline.spec.ts Normal file
View 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();
});
});
});

View file

@ -1,83 +1,208 @@
import { IssueStatus, getIssueStatus, getPRStatus } from "src/github/response";
import { getIssue, getPullRequest } from "../github/github";
import { parseUrl } from "../github/url-parse";
import { setIcon } from "obsidian";
import { setIssueIcon, setPRIcon } from "src/icon";
import { IssueStatus, getIssueStatus, getPRStatus } from "../github/response";
import { setFileIcon, setIssueIcon, setPRIcon, setPRMergeableIcon } from "../icon";
export function createTag(href: string) {
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 | null {
const parsedUrl = parseUrl(href);
const container = createEl("a", { cls: "github-link-inline", href });
// Add icon
const icon = createTagSection(container).createSpan({
cls: ["github-link-status-icon", "github-link-inline-icon"],
});
setIcon(icon, "github");
// Add repo
if (parsedUrl.repo) {
createTagSection(container).createSpan({
cls: "github-link-inline-repo",
text: parsedUrl.repo,
});
if (!parsedUrl) {
return null;
}
// fall back to org if no repo found
else if (parsedUrl.org) {
createTagSection(container).createSpan({
cls: "github-link-inline-org",
text: parsedUrl.org,
});
}
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: [],
};
createIconSection(config);
createOrgSection(config, parsedUrl);
createRepoSection(config, parsedUrl);
// 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) {
config.sections.splice(orgIndex, 1);
}
if (parsedUrl.repo && parsedUrl.org) {
// Get issue info
if (parsedUrl.issue !== undefined) {
setIssueIcon(icon, IssueStatus.Open);
const issueContainer = createTagSection(container).createSpan({
cls: "github-link-inline-issue-title",
text: `${parsedUrl.issue}`,
});
getIssue(parsedUrl.org, parsedUrl.repo, parsedUrl.issue).then((issue) => {
if (issue.title) {
const status = getIssueStatus(issue);
setIssueIcon(icon, status);
issueContainer.setText(issue.title);
}
});
createIssueSection(config, parsedUrl, container);
} else if (parsedUrl.pr !== undefined) {
createPullRequestSection(config, parsedUrl, container);
} else if (parsedUrl.code !== undefined) {
createFileSection(config, parsedUrl, container);
}
}
// Get PR info
if (parsedUrl.pr !== undefined) {
setPRIcon(icon, IssueStatus.Open);
const prContainer = createTagSection(container).createSpan({
cls: "github-link-inline-pr-title",
text: `${parsedUrl.pr}`,
});
getPullRequest(parsedUrl.org, parsedUrl.repo, parsedUrl.pr).then((pr) => {
if (pr.title) {
const status = getPRStatus(pr);
setPRIcon(icon, status);
prContainer.setText(pr.title);
}
});
}
// Add all sections
for (const section of config.sections) {
container.appendChild(createTagSection(section));
}
return container;
}
function createTagSection(parent: HTMLElement): HTMLElement {
return parent.createDiv({ cls: "github-link-inline-section" });
function createTagSection(...children: HTMLElement[]): HTMLDivElement {
const section = createDiv({ cls: "github-link-inline-section" });
section.append(...children);
return section;
}
export async function InlineRenderer(el: HTMLElement) {
const githubLinks = el.querySelectorAll<HTMLAnchorElement>(`a.external-link[href^="https://github.com"]`);
function createIconSection(config: TagConfig) {
// Set default icon
config.sections.push(config.icon);
setIcon(config.icon, "github");
}
function createOrgSection(config: TagConfig, parsedUrl: ParsedUrl) {
// Add org
if (parsedUrl.org) {
config.sections.push(
createSpan({
cls: "github-link-inline-org",
text: parsedUrl.org,
}),
);
}
}
function createRepoSection(config: TagConfig, parsedUrl: ParsedUrl) {
// Add repo
if (parsedUrl.repo) {
config.sections.push(
createSpan({
cls: "github-link-inline-repo",
text: parsedUrl.repo,
}),
);
}
}
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-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 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, container: HTMLAnchorElement) {
if (parsedUrl.pr === undefined) {
return;
}
setPRIcon(config.icon, IssueStatus.Open);
const prContainer = createSpan({
cls: "github-link-inline-pr-title",
text: `${parsedUrl.pr}`,
});
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, 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, 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);
container.appendChild(createTagSection(mergeableIcon));
}
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);
}
}
}
}

View 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);
});
});

View file

@ -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
View 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);
}
});
});
});

View file

@ -1,14 +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 { GithubLinkPluginSettings } from "./settings";
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, dataVersion: DATA_VERSION };
export const logger = new Logger();
let cache: RequestCache;
export function getCache(): RequestCache {
@ -18,20 +22,37 @@ export function getCache(): RequestCache {
export class GithubLinkPlugin extends Plugin {
public cacheInterval: number | undefined;
async onload() {
Object.assign(PluginSettings, await this.loadData());
const data = (await this.loadData()) || {};
Object.assign(PluginSettings, data.settings);
Object.assign(PluginData, data);
logger.logLevel = PluginSettings.logLevel;
cache = new RequestCache(PluginSettings.cache);
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) {
PluginSettings.cache = cache.toJSON();
await this.saveData(PluginSettings);
PluginData.cache = cache.toJSON();
await this.saveData(PluginData);
logger.info(`Cleaned ${entriesDeleted} entries from request cache.`);
}
// To show all icons, logger.debug(getIconIds());
this.addSettingTab(new GithubLinkPluginSettingsTab(this.app, this));
this.registerMarkdownPostProcessor(InlineRenderer);
this.registerEditorExtension(createInlineViewPlugin(this));
@ -43,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) {
PluginSettings.cache = cache.toJSON();
await this.saveData(PluginSettings);
cache.cacheUpdated = false;
logger.info(`Saved request cache with ${PluginSettings.cache?.length} items.`);
}
window.setInterval(() => {
void checkCache();
}, PluginSettings.cacheIntervalSeconds * 1000),
);
}

View file

@ -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}`);
}
}
}
},
},
};

View file

@ -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,
};

View file

@ -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);
}
},
},
};

View file

@ -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",

View file

@ -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 = {};

View file

@ -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("");
}
}
}
}
}

View file

@ -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;
}

View file

@ -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
View 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
View 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
View 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;
}

240
src/settings/account.ts Normal file
View file

@ -0,0 +1,240 @@
import type { App } from "obsidian";
import { Setting } from "obsidian";
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;
newAccount: GithubAccount | null = null;
constructor(
private readonly app: App,
private readonly container: HTMLElement,
private readonly saveCallback: () => Promise<void>,
private readonly displayCallback: () => void,
private readonly removeCallback: (account: GithubAccount) => Promise<void>,
) {}
public render(accounts: GithubAccount[]): void {
for (const account of accounts) {
this.renderAccountSetting(account);
}
}
public renderNewAccount(
container: HTMLElement,
saveNewAccountCallback: (account: GithubAccount) => Promise<void>,
): void {
if (!this.newAccount) {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "", customOAuth: false };
}
// TODO: Combine the new account and existing account rendering to reduce duplication
const accountContainer = container.createDiv();
const header = accountContainer.createEl("h3", { text: "New account" });
new Setting(accountContainer)
.setName("Account name")
.setDesc("Required.")
.addText((text) => {
text.setValue(this.newAccount!.name);
text.onChange((value) => {
this.newAccount!.name = value;
header.setText(value ?? "New account");
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(() => {
this.newAccount = null;
this.displayCallback();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional.",
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
text.onChange((value) => {
this.newAccount!.orgs = value.split(",").map((acc) => acc.trim());
});
});
const oauthDesc = createFragment((fragment) => {
fragment.appendText(
"You can optionally provide your own OAuth app for more control and a larger request rate limit. See the ",
);
fragment.createEl("a", {
text: "plugin documentation",
href: "https://github.com/nathonius/obsidian-github-link/wiki/Authentication#custom-oauth-app",
});
fragment.appendText(" for instructions.");
});
const customOAuthSetting = new Setting(accountContainer)
.setName("Use custom OAuth app")
.setDesc(oauthDesc)
.addToggle((toggle) => {
toggle.setValue(this.newAccount!.customOAuth ?? false);
toggle.onChange((value) => {
this.newAccount!.customOAuth = value;
this.displayCallback();
});
});
if (this.newAccount.customOAuth) {
customOAuthSetting.addText((text) => {
text.setValue(this.newAccount!.clientId ?? "");
text.setPlaceholder("Client ID");
text.onChange((value) => {
this.newAccount!.clientId = value.trim();
});
});
}
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const clientId = this.newAccount?.customOAuth ? this.newAccount.clientId : undefined;
const authResult = await auth(
this.tokenVerification.bind(this),
clientId,
)({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
this.displayCallback();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(this.newAccount!.token);
text.onChange((value) => {
this.newAccount!.token = value.trim();
});
});
new Setting(accountContainer).addButton((button) => {
button.setButtonText("Save account");
button.setTooltip("Save account");
button.setIcon("save");
button.onClick(async () => {
if (!this.newAccount?.name) {
return;
}
await saveNewAccountCallback(this.newAccount);
this.newAccount = null;
this.displayCallback();
});
});
}
public renderAccountSetting(account: GithubAccount, parent: HTMLElement = this.container): void {
const accountContainer = parent.createDiv();
accountContainer.createEl("h3", { text: account.name });
new Setting(accountContainer)
.setName("Account name")
.addText((text) => {
text.setValue(account.name);
text.onChange((value) => {
account.name = value;
void this.saveCallback();
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(async () => {
await this.removeCallback(account);
await this.saveCallback();
this.displayCallback();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc("A comma separated list of the GitHub organizations and users this account should be used for.")
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
text.onChange((value) => {
account.orgs = value.split(",").map((org) => org.trim());
void this.saveCallback();
});
});
const oauthDesc = createFragment((fragment) => {
fragment.appendText(
"You can optionally provide your own OAuth app for more control and a larger request rate limit. See the ",
);
fragment.createEl("a", { text: "plugin documentation", href: "#" });
fragment.appendText(" for instructions.");
});
const customOAuthSetting = new Setting(accountContainer)
.setName("Use custom OAuth app")
.setDesc(oauthDesc)
.addToggle((toggle) => {
toggle.setValue(account.customOAuth ?? false);
toggle.onChange((value) => {
account.customOAuth = value;
this.displayCallback();
});
});
if (account.customOAuth) {
customOAuthSetting.addText((text) => {
text.setValue(account.clientId ?? "");
text.setPlaceholder("Client ID");
text.onChange((value) => {
account.clientId = value.trim();
void this.saveCallback();
});
});
}
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const customClientId = account.customOAuth ? account.clientId : undefined;
const authResult = await auth(
this.tokenVerification.bind(this),
customClientId,
)({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
await this.saveCallback();
this.displayCallback();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(account.token);
text.onChange((value) => {
account.token = value.trim();
void this.saveCallback();
});
});
}
private tokenVerification(verification: Verification) {
this.authModal = new AuthModal(this.app, verification);
this.authModal.open();
}
}

3
src/settings/index.ts Normal file
View file

@ -0,0 +1,3 @@
export type { GithubAccount, GithubLinkPluginData, GithubLinkPluginSettings } from "./types";
export { DEFAULT_SETTINGS } from "./types";
export { GithubLinkPluginSettingsTab } from "./settings-tab";

View file

@ -2,47 +2,21 @@
import { Notice, PluginSettingTab, Setting } from "obsidian";
import type { App } from "obsidian";
import { AuthModal } from "./auth-modal";
import type { GithubLinkPlugin } from "./plugin";
import { LogLevel } from "./logger";
import { PluginSettings, getCache } from "./plugin";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { auth } from "./github/auth";
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
token: string;
}
export interface GithubLinkPluginSettings {
accounts: GithubAccount[];
defaultAccount?: string;
defaultPageSize: number;
logLevel: LogLevel;
tagTooltips: boolean;
cacheIntervalSeconds: number;
maxCacheAgeHours: number;
minRequestSeconds: number;
cache: string[] | null;
}
export const DEFAULT_SETTINGS: GithubLinkPluginSettings = {
accounts: [],
defaultPageSize: 10,
logLevel: LogLevel.Error,
tagTooltips: false,
cacheIntervalSeconds: 60,
maxCacheAgeHours: 120,
minRequestSeconds: 60,
cache: null,
};
import type { GithubLinkPlugin } from "../plugin";
import { LogLevel } from "../logger";
import { PluginData, PluginSettings, getCache } from "../plugin";
import type { GithubAccount, GithubLinkPluginData } from "./types";
import { DATA_VERSION, DEFAULT_SETTINGS } from "./types";
import { AccountSettings } from "./account";
export class GithubLinkPluginSettingsTab extends PluginSettingTab {
authModal: AuthModal | null = null;
newAccount: GithubAccount | null = null;
private readonly accountSettings = new AccountSettings(
this.app,
this.containerEl,
this.saveSettings.bind(this),
this.display.bind(this),
this.removeAccount.bind(this),
);
constructor(
public app: App,
private readonly plugin: GithubLinkPlugin,
@ -50,7 +24,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
super(app, plugin);
}
display() {
public display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "GitHub authentication" });
@ -59,16 +33,20 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
text: "No authentication is required to reference public repositories. Providing a token allows referencing private repos, but the token is stored in plain text. You can create multiple accounts for multiple tokens.",
});
new Setting(containerEl).setName("Add account").addButton((button) => {
const newAccountSection = containerEl.createDiv();
new Setting(newAccountSection).setName("Add account").addButton((button) => {
button.setButtonText("");
button.setIcon("plus");
button.setTooltip("Add Account");
button.onClick(() => {
this.newAccount = { id: crypto.randomUUID(), name: "", orgs: [], token: "" };
this.display();
this.accountSettings.renderNewAccount(newAccountSection, this.saveNewAccount.bind(this));
});
});
if (this.accountSettings.newAccount) {
this.accountSettings.renderNewAccount(newAccountSection, this.saveNewAccount.bind(this));
}
new Setting(containerEl)
.setName("Default account")
.setDesc("The account that will be used if no other users or organizations match.")
@ -88,144 +66,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
});
});
// TODO: Combine the new account and existing account rendering to reduce duplication
if (this.newAccount !== null) {
const accountContainer = containerEl.createDiv();
const header = accountContainer.createEl("h3", { text: "New account" });
new Setting(accountContainer)
.setName("Account name")
.setDesc("Required.")
.addText((text) => {
text.setValue(this.newAccount!.name);
text.onChange((value) => {
this.newAccount!.name = value;
header.setText(value ?? "New account");
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(() => {
this.newAccount = null;
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc(
"A comma separated list of the GitHub organizations and users this account should be used for. Optional.",
)
.addTextArea((text) => {
text.setValue(this.newAccount!.orgs.join(", "));
text.onChange((value) => {
this.newAccount!.orgs = value.split(",");
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens). Required.",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
this.newAccount!.token = authResult.token;
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(this.newAccount!.token);
text.onChange((value) => {
this.newAccount!.token = value;
});
});
new Setting(accountContainer).addButton((button) => {
button.setButtonText("Save account");
button.setTooltip("Save account");
button.setIcon("save");
button.onClick(async () => {
if (!this.newAccount?.name || !this.newAccount.token) {
return;
}
PluginSettings.accounts.unshift(this.newAccount);
await this.saveSettings();
this.newAccount = null;
this.display();
});
});
}
for (const account of PluginSettings.accounts) {
const accountContainer = containerEl.createDiv();
accountContainer.createEl("h3", { text: account.name });
new Setting(accountContainer)
.setName("Account name")
.addText((text) => {
text.setValue(account.name);
text.onChange((value) => {
account.name = value;
this.saveSettings();
});
})
.addButton((button) => {
button.setIcon("trash");
button.setTooltip("Delete account");
button.onClick(async () => {
PluginSettings.accounts.remove(account);
await this.saveSettings();
this.display();
});
});
new Setting(accountContainer)
.setName("Orgs and users")
.setDesc("A comma separated list of the GitHub organizations and users this account should be used for.")
.addTextArea((text) => {
text.setValue(account.orgs.join(", "));
text.onChange((value) => {
account.orgs = value.split(",").map((org) => org.trim());
this.saveSettings();
});
});
new Setting(accountContainer)
.setName("Token")
.setDesc(
"A GitHub token, which can be generated automatically (recommended) or by creating a personal access token (not recommended unless org does not allow OAuth tokens).",
)
.addButton((button) => {
button.setButtonText("Generate Token");
button.onClick(async () => {
const authResult = await auth(this.tokenVerification.bind(this))({
type: "oauth",
});
this.authModal?.close();
this.authModal = null;
account.token = authResult.token;
await this.saveSettings();
this.display();
});
})
.addText((text) => {
text.setPlaceholder("Personal Access Token / OAuth Token");
text.setValue(account.token);
text.onChange((value) => {
account.token = value;
this.saveSettings();
});
});
}
this.accountSettings.render(PluginSettings.accounts);
containerEl.createEl("h2", { text: "Other settings" });
@ -242,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();
});
});
@ -261,7 +142,40 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
toggle.setValue(PluginSettings.tagTooltips);
toggle.onChange((value) => {
PluginSettings.tagTooltips = value;
this.saveSettings();
void this.saveSettings();
});
});
new Setting(containerEl)
.setName("Pull request mergeability")
.setDesc("Add an icon to pull request tags to show whether or not the PR is mergeable")
.addToggle((toggle) => {
toggle.setValue(PluginSettings.tagShowPRMergeable);
toggle.onChange((value) => {
PluginSettings.tagShowPRMergeable = value;
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();
});
});
@ -284,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();
});
@ -312,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();
});
});
@ -340,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();
});
});
@ -361,7 +296,7 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
button.setButtonText("Clear cache");
button.onClick(async () => {
const itemsDeleted = getCache().clean(new Date());
PluginSettings.cache = null;
PluginData.cache = null;
await this.saveSettings();
new Notice(`Removed ${itemsDeleted} stored items from GitHub Link cache.`, 3000);
});
@ -389,17 +324,27 @@ export class GithubLinkPluginSettingsTab extends PluginSettingTab {
dropdown.setValue(PluginSettings.logLevel.toString());
dropdown.onChange((value) => {
PluginSettings.logLevel = Number(value);
this.saveSettings();
void this.saveSettings();
});
});
}
private saveSettings() {
return this.plugin.saveData(PluginSettings);
const newData: GithubLinkPluginData = {
cache: PluginData.cache,
settings: PluginSettings,
dataVersion: DATA_VERSION,
};
return this.plugin.saveData(newData);
}
private tokenVerification(verification: Verification) {
this.authModal = new AuthModal(this.app, verification);
this.authModal.open();
private async removeAccount(account: GithubAccount): Promise<void> {
PluginSettings.accounts.remove(account);
await this.saveSettings();
}
private async saveNewAccount(account: GithubAccount): Promise<void> {
PluginSettings.accounts.unshift(account);
await this.saveSettings();
}
}

51
src/settings/types.ts Normal file
View file

@ -0,0 +1,51 @@
import { LogLevel } from "../logger";
export const DATA_VERSION = 1;
export interface GithubAccount {
id: string;
name: string;
orgs: string[];
token: string;
customOAuth?: boolean;
clientId?: string;
}
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;
}
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,
};

View file

@ -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(" ");
@ -11,6 +20,61 @@ export function valueWithin(value: number, min: number, max: number) {
return value >= min && value <= max;
}
export function assertDefined<T>(value: T | undefined | null): asserts value is T {
if (value === undefined || value === null) {
throw new Error(`Expected value to be defined!`);
}
}
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)
@ -51,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;
@ -62,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];
}
}
@ -122,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;
}
}

View file

@ -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,7 +180,16 @@ 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;
}
@ -189,9 +203,6 @@ body.theme-dark {
background-color: var(--gh-color-canvas-inset);
}
.github-link-status-icon {
display: inline-flex;
}
.github-link-status-icon[data-status="open"] {
color: var(--gh-color-open-fg);
}
@ -207,6 +218,12 @@ body.theme-dark {
.github-link-status-icon[data-status="done"] {
color: var(--gh-color-done-fg);
}
.github-link-inline-pr-mergeable-icon[data-status="true"] {
color: var(--gh-color-success-fg);
}
.github-link-inline-pr-mergeable-icon[data-status="false"] {
color: var(--gh-color-danger-fg);
}
.github-link-inline-section:has(> .github-link-inline-icon[data-status="open"]) {
background-color: var(--gh-color-open-subtle);
@ -233,6 +250,20 @@ body.theme-dark {
border-color: var(--gh-color-done-emphasis);
}
.github-link-inline-section:has(> .github-link-inline-pr-mergeable-icon[data-status="true"]) {
background-color: var(--gh-color-success-subtle);
border-color: var(--gh-color-success-emphasis);
}
.github-link-inline-section:has(> .github-link-inline-pr-mergeable-icon[data-status="false"]) {
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);
color: var(--gh-color-accent-fg);
@ -246,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);
@ -263,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);
@ -291,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;
}
@ -304,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
View 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;
}

View file

@ -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"]
}

View file

@ -1,14 +1,30 @@
import { readFileSync, writeFileSync } from "fs";
import fs from "fs";
import { execSync } from "child_process";
const targetVersion = process.env.npm_package_version;
const version = process.argv[2];
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
if (!version) {
console.error("Please provide a version number as a command line argument.");
process.exit(1);
}
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
// Update package.json
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
packageJson.version = version;
fs.writeFileSync("package.json", JSON.stringify(packageJson, null, "\t"));
// Update manifest.json
const manifestJson = JSON.parse(fs.readFileSync("manifest.json", "utf8"));
manifestJson.version = version;
fs.writeFileSync("manifest.json", JSON.stringify(manifestJson, null, "\t"));
// Update versions.json
const versionsJson = JSON.parse(fs.readFileSync("versions.json", "utf8"));
const hostSoftwareVersion = Object.values(versionsJson)[0]; // Assuming the first entry is the previous version
const newVersionsJson = { [version]: hostSoftwareVersion, ...versionsJson };
fs.writeFileSync("versions.json", JSON.stringify(newVersionsJson, null, "\t"));
// Update package-lock.json by running npm install
execSync("npm install", { stdio: "inherit" });
console.log("Version updated successfully.");

View file

@ -1,4 +1,12 @@
{
"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",
"0.5.1": "0.15.0",
"0.5.0": "0.15.0",
@ -6,4 +14,4 @@
"0.3.0": "0.15.0",
"0.2.0": "0.15.0",
"0.1.0": "0.15.0"
}
}