1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-02-06 23:32:58 +00:00

86 Commits
0.1.1 ... 0.3.0

Author SHA1 Message Date
e83d70fbd9 Merge pull request #42 from koloml/release/0.3
Release: 0.3
2024-10-20 04:15:24 +04:00
844853ff57 Applied npm audit fix, updated lock file with new version 2024-10-20 04:13:27 +04:00
774409aac6 Bumped vite from 5.0.3 to 5.4.9 2024-10-20 04:12:12 +04:00
917775c5cd Bumped svelte from 4.2.7 to 4.2.19 2024-10-20 04:11:46 +04:00
a68c261b52 Bumped version to 0.3.0 2024-10-20 04:02:19 +04:00
f3a9694b1b Merge pull request #43 from koloml/feature/fullscreen-component
Fullscreen Viewer: Moved viewer logic into separate component, added closing with swipe for touch devices, added scroll lock
2024-10-20 03:55:38 +04:00
2cb4c6b4b2 Refactoring fullscreen viewer, added close swipe action for mobile 2024-10-20 03:47:18 +04:00
bafdb68f1e Added return type 2024-10-20 00:38:22 +04:00
02f9f3b36e Merge pull request #41 from koloml/feature/color-tags-in-editor
Tag Editor: Automatically apply category colors to the tags in the tag editor
2024-10-12 20:49:59 +04:00
57c505bee9 Dynamically catch and refresh colors in tag editor 2024-10-12 20:15:59 +04:00
03512a6539 Copying tag colors into the tag editor using other tags on the page 2024-10-12 19:15:04 +04:00
f95eaacaaa Merge pull request #40 from koloml/feature/add-tag-to-profile
Tagging Profiles: Added option to quickly add the tag into the active profile from the dropdown context menu
2024-10-12 18:46:16 +04:00
38cb925fa4 Implemented option to add the tag into active profile from dropdown 2024-10-12 03:40:38 +04:00
7dd738d0e8 Merge pull request #39 from koloml/feature/renaming-settings-route
Renamed `/settings` path to `/features` to avoid confusion with `/preferences`
2024-09-30 17:49:40 +04:00
2f8a47b808 Moving /settings route to /features to avoid confusion 2024-09-30 17:46:29 +04:00
d0c910d5bb Merge pull request #31 from koloml/feature/allow-tags-popup-in-galleries-listing
Show the tagging profiles popup in galleries view
2024-09-30 17:40:54 +04:00
dc1e49e60c Merge pull request #30 from koloml/feature/storage-viewer
Added debug section with extension's storage viewer
2024-09-30 17:40:44 +04:00
5e7e92614d Merge pull request #35 from koloml/feature/npm-audit
Updating vulnerable dependencies via `npm audit fix`
2024-08-26 22:22:06 +04:00
727b2c81ff Updating vulnerable dependencies via npm audit fix
Details of report:

# npm audit report

braces  <3.0.3
Severity: high
Uncontrolled resource consumption in braces - https://github.com/advisories/GHSA-grv7-fg5c-xmjg
fix available via `npm audit fix`
node_modules/braces

micromatch  <4.0.8
Severity: moderate
Regular Expression Denial of Service (ReDoS) in micromatch - https://github.com/advisories/GHSA-952p-6rrq-rcjv
fix available via `npm audit fix`
node_modules/micromatch

vite  5.1.0 - 5.1.6
Severity: moderate
Vite's `server.fs.deny` did not deny requests for patterns with directories. - https://github.com/advisories/GHSA-8jhw-289h-jh2g
fix available via `npm audit fix`
node_modules/vite

3 vulnerabilities (2 moderate, 1 high)
2024-08-26 22:09:17 +04:00
8059c93baa Display types correctly for the value 2024-08-12 20:04:02 +04:00
2f8d608e6b Fixed missing returning statement when updating Writeable 2024-08-12 20:02:35 +04:00
4635ccdb2b Fixed breadcrumbs generation 2024-08-12 19:55:11 +04:00
68d1d726af Added debug section to inspect extension's local storage 2024-08-12 19:37:35 +04:00
e8c3e610eb Support tagging profiles on galleries 2024-08-11 17:53:18 +04:00
f9cb73bafc Added missing alt-text for the chrome installation link 2024-08-10 16:33:44 +04:00
6bb3e83684 Added installation buttons for the Chrome & Firefox 2024-08-10 16:32:10 +04:00
b99846ba6a Merge pull request #26 from koloml/release/0.2.1
Release: 0.2.1
2024-08-10 15:10:14 +04:00
4ca84b0c14 Bump version to 0.2.1 2024-08-10 15:09:51 +04:00
25fe769a1e Merge pull request #27 from koloml/feature/misc-preferences
Fullscreen Viewer: Added preference to turn the fullscreen button ON and OFF
2024-08-10 15:09:20 +04:00
c9d20be33d Turn on the fullscreen viewer button by default 2024-08-10 15:05:09 +04:00
e9b68137de Apply the preference 2024-08-10 15:04:00 +04:00
e0820c50ec Added settings for misc. & tools preferences with fullscreen option 2024-08-10 15:03:39 +04:00
135ed48c01 Merge pull request #25 from koloml/feature/consistent-sorting
Sort listing of profiles & tags in profile view alphabetically
2024-08-10 14:42:14 +04:00
9d9aa38a9d Merge pull request #24 from koloml/bugfix/active-tagging-profile-resetting
Tagging Profiles: Fixed active profile selection getting reset on popup being opened
2024-08-10 14:42:01 +04:00
323fa4e2b7 Sort tags alphabetically 2024-08-10 14:37:02 +04:00
920804467e Invert sorting of tagging profiles list 2024-08-10 14:32:21 +04:00
9c66f62408 Wait for initial loading before subscribing to changes 2024-08-10 13:53:19 +04:00
16d126598e Merge pull request #23 from koloml/hotfix/firefox-missing-extension-id
Fixed missing extension ID required for publishing to Firefox extensions store
2024-08-08 08:00:09 +04:00
a93430f3e3 Bumped version to 0.2.0.1 2024-08-08 07:54:20 +04:00
1927c2ec31 Fixed missing extension ID for FireFox 2024-08-08 07:53:53 +04:00
63e6ee394d Merge pull request #16 from koloml/release/0.2
Release: v0.2.0
2024-08-08 07:29:54 +04:00
342cc38292 Bumping version to 0.2.0 2024-08-08 07:29:20 +04:00
f11827d516 Merge pull request #22 from koloml/feature/firefox-support
Tested for Firefox, preparing for release to Firefox addon store
2024-08-08 07:27:31 +04:00
595e73aff3 Added basic building instructions 2024-08-08 07:23:36 +04:00
c9107ab109 Fixed appearance when popup is opened in the new tab instead of popup 2024-08-08 06:57:54 +04:00
3adfab9555 De-Chrome-ify helper class directory 2024-08-08 06:15:28 +04:00
0a740273a3 Merge pull request #21 from koloml/feature/improving-mobile-controls
Tag Editor: Added better support for mobile devices
2024-08-08 05:53:56 +04:00
e325e51b41 Stretch field to 100% of width 2024-08-08 05:36:26 +04:00
2637eac162 Fixed Enter and Backspace getting missed on mobile devices 2024-08-08 05:33:34 +04:00
64ad82b985 Merge pull request #20 from koloml/feature/configuration-section
Added the preferences section, implemented preferences to toggle properties suggestions
2024-08-07 03:59:05 +04:00
5bb6055aee Changed texts, hiding the position option when disabled 2024-08-07 03:55:04 +04:00
56f397c2d8 Update default appearance of selectors 2024-08-07 03:49:39 +04:00
e85055368a Apply preferences to the search wrapper 2024-08-07 03:39:56 +04:00
0265622337 Merge remote-tracking branch 'refs/remotes/origin/release/0.2' into feature/configuration-section 2024-08-07 03:28:13 +04:00
91648a1ee0 Merge pull request #19 from koloml/feature/autocomplete-property-queries
Autocompletion: Added completion for the properties in header search field
2024-08-07 03:27:34 +04:00
fa77e8b923 Added search settings controller, sync settings in popup with the class 2024-08-07 03:26:32 +04:00
67d3b57eb1 Implemented base class for cached settings storages 2024-08-07 03:26:06 +04:00
b0889486c7 Added search preferences section with several initial settings 2024-08-07 02:30:24 +04:00
c0b1259e45 Added select and checkboxes fields 2024-08-07 02:29:31 +04:00
8aacd83474 Support form controls without labels, added paddings 2024-08-07 02:29:19 +04:00
be1ae8f004 Added undocumented faved_by_id and uploader_id 2024-07-22 22:05:32 +04:00
3f22852714 Added autocompletion of my: namespace 2024-07-22 22:05:09 +04:00
71ab75efaf Partially restored header logic, added suggestions for properties 2024-07-15 03:54:28 +04:00
f71dc5a029 Merge pull request #17 from koloml/feature/tagging-profiles-import-export
Added functionality to export and import tagging profiles
2024-07-07 19:45:07 +04:00
1a086625b9 Implemented the importing procedure for tagging profiles 2024-07-07 19:35:27 +04:00
60914e11c4 Moved styling of the textarea inside FormControl 2024-07-07 19:09:19 +04:00
d3fc78533d Extracted the logic of previewing the tagging profiles into component 2024-07-07 19:08:31 +04:00
b240c2aefe Added error & warning colors 2024-07-07 19:07:21 +04:00
e1026fd108 Fixed error triggered when empty string is parsed as null 2024-07-07 19:07:03 +04:00
81c66134a1 Added import logic, moved logic to the profile class 2024-07-07 17:18:26 +04:00
8d8d7cc7e4 Force cursor to point, disabled user selection 2024-07-07 16:51:31 +04:00
688ed15939 Added exporting view for a tagging profile 2024-07-07 05:58:31 +04:00
d671ca13f6 Installed lz-string which will be used to compress exported data 2024-07-07 05:57:54 +04:00
aaa1441a38 Added file-export icon, moved some the types to typedef file 2024-07-07 05:10:17 +04:00
b7a53daa9b Merge pull request #15 from koloml/feature/menu-items-controls
Minor refactoring for menu items, support switching between tagging profiles using radio controls right from the list
2024-07-07 03:19:23 +04:00
965045e672 Merge pull request #14 from koloml/feature/link-latest-release-to-github
Linking the version in the popup footer to the latest release page
2024-07-07 03:19:00 +04:00
7ee1a72302 Added radio item, replaced menu item on profiles listing screen 2024-07-07 03:10:50 +04:00
dc27f33231 Renaming all instances of MenuLink to MenuItem 2024-07-07 02:52:36 +04:00
eae5016daa Support links without URLs provided 2024-07-07 02:48:41 +04:00
7b236f12cd Added link to the repository release for current version 2024-07-07 01:45:15 +04:00
7eab8d633f Bumping version to 0.1.2 2024-07-05 02:12:59 +04:00
68de994811 Merge pull request #13 from koloml/prevent-closing-tab-before-submission-completed
Prompt the browser confirmation popup when attempting to change or close the tab before all submissions are processed
2024-07-05 02:05:59 +04:00
be4aec54fe Merge pull request #12 from koloml/disable-search-autocompletion
Removing auto-completion logic
2024-07-05 02:05:43 +04:00
9ca663ffdb Added icon for failed status 2024-07-05 02:00:36 +04:00
bb0f84c9ad Showing exit popup when leaving page before submissions are processed 2024-07-05 01:57:34 +04:00
c8eb54ab98 Removing auto-completion logic 2024-07-05 01:06:11 +04:00
65 changed files with 5652 additions and 3949 deletions

BIN
.github/assets/chrome.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
.github/assets/firefox.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,4 +1,29 @@
# Furbooru Tagging Assistant
[![Get the Add-on on Firefox](.github/assets/firefox.png)](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
[![Get the extension on Chrome](.github/assets/chrome.png)](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
tag the images more easily and quickly.
tag the images more easily and quickly.
## Building
Recommendations on environment:
- Recommended version of Node.js: LTS (20)
First you need to clone the repository and install all packages:
```shell
npm install --save-dev
```
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
content scripts/stylesheets and copy the manifest afterward. Simply run:
```shell
npm run build
```
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.

View File

@@ -1,7 +1,12 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.1.1",
"version": "0.3.0",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
@@ -18,7 +23,8 @@
"*://*.furbooru.org/",
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags/*"
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
@@ -33,9 +39,31 @@
],
"js": [
"src/content/header.js"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
"*://*.furbooru.org/images/*",
"*://*.furbooru.org/images/*/tag_changes",
"*://*.furbooru.org/images/*/tag_changes?*",
"*://*.furbooru.org/search?*",
"*://*.furbooru.org/tags?*",
"*://*.furbooru.org/tags/*",
"*://*.furbooru.org/profiles/*/tag_changes",
"*://*.furbooru.org/profiles/*/tag_changes?*",
"*://*.furbooru.org/filters/*"
],
"css": [
"src/styles/content/header.scss"
"js": [
"src/content/tags.js"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.js"
]
}
],

7122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.1.1",
"version": "0.3.0",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
@@ -17,10 +17,13 @@
"@types/chrome": "^0.0.262",
"cheerio": "^1.0.0-rc.12",
"sass": "^1.71.0",
"svelte": "^4.2.7",
"svelte": "^4.2.19",
"svelte-check": "^3.6.0",
"typescript": "^5.0.0",
"vite": "^5.0.3"
"vite": "^5.4.9"
},
"type": "module"
"type": "module",
"dependencies": {
"lz-string": "^1.5.0"
}
}

11
src/app.d.ts vendored
View File

@@ -7,6 +7,17 @@ declare global {
// interface PageData {}
// interface PageState {}
// interface Platform {}
type LinkTarget = "_blank" | "_self" | "_parent" | "_top";
type IconName = (
"tag"
| "paint-brush"
| "arrow-left"
| "info-circle"
| "wrench"
| "globe"
| "plus"
| "file-export"
);
}
}

View File

@@ -0,0 +1,93 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {storagesCollection} from "$stores/debug.js";
import {goto} from "$app/navigation";
import {findDeepObject} from "$lib/utils.js";
/** @type {string} */
export let storage;
/** @type {string[]} */
export let path;
/** @type {Object|null} */
let targetStorage = null;
/** @type {[string, string][]} */
let breadcrumbs = [];
/** @type {Object<string, any>|null} */
let targetObject = null;
let targetPathString = '';
$: {
/** @type {[string, string][]} */
const builtBreadcrumbs = [];
breadcrumbs = path.reduce((resultCrumbs, entry) => {
let entryPath = entry;
if (resultCrumbs.length) {
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
}
resultCrumbs.push([entry, entryPath]);
return resultCrumbs;
}, builtBreadcrumbs);
targetPathString = path.join("/");
if (targetPathString.length) {
targetPathString += "/";
}
}
$: {
targetStorage = $storagesCollection[storage];
if (!targetStorage) {
goto("/preferences/debug/storage");
}
}
$: {
targetObject = targetStorage
? findDeepObject(targetStorage, path)
: null;
}
</script>
<Menu>
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<p class="path">
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
{#each breadcrumbs as [name, entryPath]}
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
{/each}
</p>
{#if targetObject}
<Menu>
<hr>
{#each Object.entries(targetObject) as [key, value]}
{#if targetObject[key] && typeof targetObject[key] === 'object'}
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
{key}: Object
</MenuItem>
{:else}
<MenuItem>
{key}: {typeof targetObject[key]} = {targetObject[key]}
</MenuItem>
{/if}
{/each}
</Menu>
{/if}
<style lang="scss">
.path {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
</style>

View File

@@ -3,17 +3,30 @@
</script>
<footer>
v{version}, made with ♥ by KoloMl.
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
v{version}
</a>
<span>, made with ♥ by KoloMl.</span>
</footer>
<style lang="scss">
@use 'src/styles/colors';
footer {
display: flex;
width: 100%;
background: colors.$footer;
color: colors.$footer-text;
padding: 0 24px;
font-size: 12px;
line-height: 36px;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script>
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default} */
export let profile;
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
</script>
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<script>
/** @type {string|undefined} */
export let name = undefined;
/** @type {boolean} */
export let checked;
</script>
<input type="checkbox" {name} bind:checked={checked}>
<span>
<slot></slot>
</span>

View File

@@ -1,7 +1,7 @@
<script>
/** @type {string|undefined} */
export let label;
export let label = undefined;
</script>
<label class="control">
@@ -12,5 +12,16 @@
</label>
<style lang="scss">
.label {
margin-bottom: .5em;
}
.control {
padding: 5px 0;
:global(textarea) {
width: 100%;
resize: vertical;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<script>
/**
* @type {string[]|Record<string, string>}
*/
export let options = [];
/** @type {string|undefined} */
export let name = undefined;
/** @type {string|undefined} */
export let id = undefined;
/** @type {string|undefined} */
export let value = undefined;
/** @type {Record<string, string>} */
const optionPairs = {};
if (Array.isArray(options)) {
for (let option of options) {
optionPairs[option] = option;
}
} else if (options && typeof options === 'object') {
Object.keys(options).forEach((key) => {
optionPairs[key] = options[key];
})
}
</script>
<select {name} {id} bind:value={value}>
{#each Object.entries(optionPairs) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<style lang="scss">
select {
width: 100%;
}
</style>

View File

@@ -10,3 +10,9 @@
</script>
<input type="text" {name} {placeholder} bind:value={value}>
<style lang="scss">
:global(.control) input {
width: 100%;
}
</style>

View File

@@ -9,11 +9,11 @@
display: flex;
flex-direction: column;
& > :global(a) {
& > :global(.menu-item) {
padding: 5px 24px;
}
:global(a) {
:global(.menu-item) {
color: colors.$text;
&:hover {

View File

@@ -0,0 +1,41 @@
<script>
/**
* @type {string|null}
*/
export let href = null;
/**
* @type {App.IconName|null}
*/
export let icon = null;
/**
* @type {App.LinkTarget|undefined}
*/
export let target = undefined;
</script>
<svelte:element this="{href ? 'a': 'span'}" class="menu-item" {href} {target} on:click role="link" tabindex="0">
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
<slot></slot>
</svelte:element>
<style lang="scss">
@use '../../../styles/colors';
.menu-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
}
</style>

View File

@@ -1,41 +0,0 @@
<script>
/**
* @type {string}
*/
export let href;
/**
* @type {"tag"|"paint-brush"|"arrow-left"|"info-circle"|"wrench"|"globe"|"plus"|null}
*/
export let icon = null;
/**
* @type {"_blank"|"_self"|"_parent"|"_top"|undefined}
*/
export let target = undefined;
</script>
{#if href}
<a {href} {target} on:click>
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
<slot></slot>
</a>
{/if}
<style lang="scss">
@use '../../../styles/colors';
a {
display: flex;
align-items: center;
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script>
import MenuLink from "$components/ui/menu/MenuItem.svelte";
/**
* @type {boolean}
*/
export let checked;
/**
* @type {string}
*/
export let name;
/**
* @type {string}
*/
export let value;
/**
* @type {string|null}
*/
export let href = null;
</script>
<MenuLink {href}>
<input type="radio" {name} {value} {checked} on:input on:click|stopPropagation>
<slot></slot>
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
}
</style>

View File

@@ -59,15 +59,19 @@
/**
* Handle adding new tags to the list or removing them when backspace is pressed.
*
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
* empty, while usually it should contain proper button code.
*
* @param {KeyboardEvent} event
*/
function handleKeyPresses(event) {
if (event.code === 'Enter' && addedTagName.length) {
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
addTag(addedTagName)
addedTagName = '';
}
if (event.code === 'Backspace' && !addedTagName.length && tags?.length) {
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
removeTag(tags[tags.length - 1]);
}
}
@@ -82,7 +86,11 @@
role="button" tabindex="0">x</span>
</div>
{/each}
<input type="text" bind:value={addedTagName} on:keydown={handleKeyPresses}/>
<input type="text"
bind:value={addedTagName}
on:keydown={handleKeyPresses}
autocomplete="off"
autocapitalize="none"/>
</div>
<style lang="scss">
@@ -90,5 +98,9 @@
display: flex;
flex-wrap: wrap;
gap: 6px;
input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,3 @@
import {TagsForm} from "$lib/components/TagsForm.js";
TagsForm.watchForEditors();

5
src/content/tags.js Normal file
View File

@@ -0,0 +1,5 @@
import {wrapTagDropdown} from "$lib/components/TagDropdownWrapper.js";
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -0,0 +1,211 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
export class FullscreenViewer extends BaseComponent {
/** @type {HTMLVideoElement} */
#videoElement;
/** @type {HTMLImageElement} */
#imageElement;
/** @type {number|null} */
#touchId = null;
/** @type {number|null} */
#startX = null;
/** @type {number|null} */
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
/**
* @protected
*/
build() {
this.container.classList.add('fullscreen-viewer');
this.#videoElement = document.createElement('video');
this.#imageElement = document.createElement('img');
}
/**
* @protected
*/
init() {
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
this.on('click', this.#close.bind(this));
this.on('touchstart', this.#onTouchStart.bind(this));
this.on('touchmove', this.#onTouchMove.bind(this));
this.on('touchend', this.#onTouchEnd.bind(this));
}
/**
* @param {TouchEvent} event
*/
#onTouchStart(event) {
if (this.#touchId !== null) {
return;
}
const firstTouch = event.touches.item(0);
if (!firstTouch) {
return;
}
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
/**
* @param {TouchEvent} event
*/
#onTouchEnd(event) {
if (this.#touchId === null) {
return;
}
const endedTouch = Array.from(event.changedTouches)
.find(touch => touch.identifier === this.#touchId);
if (!endedTouch) {
return;
}
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
const requiredClosingDistance = window.innerHeight / 3;
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
this.#close();
}
this.#touchId = null;
this.#startX = null;
this.#startY = null;
this.#isClosingSwipeStarted = null;
this.container.classList.remove(FullscreenViewer.#swipeState);
requestAnimationFrame(() => {
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
});
}
/**
* @param {TouchEvent} event
*/
#onTouchMove(event) {
if (this.#touchId === null) {
return;
}
if (this.#isClosingSwipeStarted === false) {
return;
}
for (const changedTouch of event.changedTouches) {
if (changedTouch.identifier !== this.#touchId) {
continue;
}
const verticalDistance = changedTouch.clientY - this.#startY;
if (this.#isClosingSwipeStarted === null) {
const horizontalDistance = changedTouch.clientX - this.#startX;
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = true;
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
this.#isClosingSwipeStarted = false;
break;
} else {
break;
}
}
this.container.style.setProperty(
FullscreenViewer.#offsetProperty,
verticalDistance.toString().concat('px')
);
const maxDistance = window.innerHeight * 2;
let opacity = 1;
if (verticalDistance !== 0) {
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
}
this.container.style.setProperty(
FullscreenViewer.#opacityProperty,
opacity.toString()
);
break;
}
}
/**
* @param {KeyboardEvent} event
*/
#onDocumentKeyPressed(event) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
#close() {
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
this.#videoElement.pause();
this.#videoElement.remove();
});
}
/**
* @param {string} url
*/
show(url) {
requestAnimationFrame(() => {
this.container.classList.add(FullscreenViewer.#shownState);
document.body.style.overflow = 'hidden';
});
if (FullscreenViewer.#isVideoUrl(url)) {
this.#imageElement.remove();
this.#videoElement.src = url;
this.#videoElement.volume = 0;
this.#videoElement.autoplay = true;
this.#videoElement.loop = true;
this.#videoElement.controls = true;
this.container.append(this.#videoElement);
return;
}
this.#videoElement.remove();
this.#imageElement.src = url;
this.container.append(this.#imageElement);
}
/**
* @param {string} url
* @return {boolean}
*/
static #isVideoUrl(url) {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
}

View File

@@ -1,15 +1,19 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
import {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {MediaBoxTools}
*/
#mediaBoxTools;
#isFullscreenButtonEnabled = false;
build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#resolveFullscreenViewer();
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
init() {
@@ -20,99 +24,63 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
this.on('click', this.#onButtonClicked.bind(this));
if (ImageShowFullscreenButton.#miscSettings) {
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
.then(isEnabled => {
this.#isFullscreenButtonEnabled = isEnabled;
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
this.#updateFullscreenButtonVisibility();
})
})
}
}
#updateFullscreenButtonVisibility() {
this.container.classList.toggle('is-visible', this.#isFullscreenButtonEnabled);
}
#onButtonClicked() {
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
const largeSourceUrl = this.#mediaBoxTools.mediaBox.imageLinks.large;
let imageElement = imageViewer.querySelector('img');
let videoElement = imageViewer.querySelector('video');
if (imageElement) {
imageElement.remove();
}
if (videoElement) {
videoElement.remove();
}
if (largeSourceUrl.endsWith('.webm') || largeSourceUrl.endsWith('.mp4')) {
videoElement ??= document.createElement('video');
videoElement.src = largeSourceUrl;
videoElement.volume = 0;
videoElement.autoplay = true;
videoElement.loop = true;
videoElement.controls = true;
imageViewer.appendChild(videoElement);
} else {
imageElement ??= document.createElement('img');
imageElement.src = largeSourceUrl;
imageViewer.appendChild(imageElement);
}
imageViewer.classList.add('shown');
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
}
/**
* @type {HTMLElement|null}
* @type {FullscreenViewer|null}
*/
static #fullscreenViewerElement = null;
static #viewer = null;
/**
* @return {HTMLElement}
* @return {FullscreenViewer}
*/
static #resolveFullscreenViewer() {
this.#fullscreenViewerElement ??= this.#buildFullscreenViewer();
return this.#fullscreenViewerElement;
static #resolveViewer() {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
/**
* @return {HTMLElement}
* @return {FullscreenViewer}
*/
static #buildFullscreenViewer() {
static #buildViewer() {
const element = document.createElement('div');
element.classList.add('fullscreen-viewer');
const viewer = new FullscreenViewer(element);
viewer.initialize();
document.body.append(element);
document.addEventListener('keydown', event => {
// When ESC pressed
if (event.code === 'Escape' || event.code === 'Esc') {
this.#closeFullscreenViewer(element);
}
});
element.addEventListener('click', () => {
this.#closeFullscreenViewer(element);
});
return element;
return viewer;
}
/**
* @param {HTMLElement} [viewerElement]
* @type {MiscSettings|null}
*/
static #closeFullscreenViewer(viewerElement = null) {
viewerElement ??= this.#resolveFullscreenViewer();
viewerElement.classList.remove('shown');
/** @type {HTMLVideoElement} */
const videoElement = viewerElement.querySelector('video');
if (!videoElement) {
return;
}
// Stopping and muting the video
requestAnimationFrame(() => {
videoElement.volume = 0;
videoElement.pause();
videoElement.remove();
})
}
static #miscSettings = null;
}
export function createImageShowFullscreenButton() {

View File

@@ -151,6 +151,11 @@ export class MaintenancePopup extends BaseComponent {
}
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
// Notify only once, when first planning to submit
if (!this.#isPlanningToSubmit) {
MaintenancePopup.#notifyAboutPendingSubmission(true);
}
this.#isPlanningToSubmit = true;
this.emit('maintenance-state-change', 'waiting');
}
@@ -181,20 +186,32 @@ export class MaintenancePopup extends BaseComponent {
this.emit('maintenance-state-change', 'processing');
const maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
let maybeTagsAndAliasesAfterUpdate;
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
tagsList => {
for (let tagName of this.#tagsToRemove) {
tagsList.delete(tagName);
}
return tagsList;
}
);
for (let tagName of this.#tagsToAdd) {
tagsList.add(tagName);
}
return tagsList;
}
);
} catch (e) {
console.warn('Tags submission failed:', e);
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.emit('maintenance-state-change', 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
@@ -206,6 +223,7 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsToRemove.clear();
this.#refreshTagsList();
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#isSubmitting = false;
}
@@ -254,12 +272,12 @@ export class MaintenancePopup extends BaseComponent {
}
});
const unsubscribeFromMaintenanceSettings = MaintenanceSettings.subscribe(settings => {
if (settings.activeProfileId === lastActiveProfileId) {
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
if (settings.activeProfile === lastActiveProfileId) {
return;
}
lastActiveProfileId = settings.activeProfileId;
lastActiveProfileId = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
@@ -276,9 +294,42 @@ export class MaintenancePopup extends BaseComponent {
}
}
/**
* Notify the frontend about new pending submission started.
* @param {boolean} isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
}
this.#pendingSubmissionCount += isStarted ? 1 : -1;
}
/**
* Subscribe to the global window closing event, show the prompt when there are pending submission.
*/
static #initializeExitPromptHandler() {
window.addEventListener('beforeunload', event => {
if (!this.#pendingSubmissionCount) {
return;
}
event.preventDefault();
event.returnValue = true;
});
}
static #scrapedAPI = new ScrapedAPI();
static #delayBeforeSubmissionMs = 500;
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
* @type {number|null}
*/
static #pendingSubmissionCount = null;
}
export function createMaintenancePopup() {

View File

@@ -38,7 +38,11 @@ export class MaintenanceStatusIcon extends BaseComponent {
break;
case "complete":
this.container.innerText = '✅'
this.container.innerText = '✅';
break;
case "failed":
this.container.innerText = '⚠️';
break;
default:

View File

@@ -1,44 +1,63 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
#searchField = null;
/** @type {HTMLInputElement|null} */
#autoCompleteField = null;
/** @type {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
#searchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
build() {
this.container.classList.add('header__search--completable');
this.#searchField = this.container.querySelector('input[name=q]');
this.#searchField.autocomplete = 'off'; // Browser's auto-complete will get in the way!
const autoCompleteField = document.createElement('input');
autoCompleteField.dataset.ac = 'true';
autoCompleteField.dataset.acMinLength = '3';
autoCompleteField.dataset.acSource = '/autocomplete/tags?term=';
autoCompleteField.classList.add('search-autocomplete-dummy');
this.#autoCompleteField = autoCompleteField;
this.container.appendChild(autoCompleteField);
}
init() {
this.#searchField.addEventListener('input', this.#updateAutoCompletedFragment.bind(this));
this.#searchField.addEventListener('keydown', this.#onSearchFieldKeyPressed.bind(this));
this.#searchField.addEventListener('selectionchange', this.#updateAutoCompletedFragment.bind(this));
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
this.#searchSettings.resolvePropertiesSuggestionsPosition()
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
});
}
#updateAutoCompletedFragment() {
const searchableFragment = this.#findCurrentTagFragment();
this.#emitAutoComplete(searchableFragment || '');
/**
* Catch the user input and execute suggestions logic.
* @param {InputEvent} event Source event to find the input element from.
*/
#onInputFindProperties(event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled) {
return;
}
const currentFragment = this.#findCurrentTagFragment();
if (!currentFragment) {
return;
}
this.#renderSuggestions(
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
event.currentTarget
);
}
/**
* Get the selection position in the search field.
* @return {number}
*/
#getInputUserSelection() {
return Math.min(
this.#searchField.selectionStart,
@@ -46,6 +65,10 @@ export class SearchWrapper extends BaseComponent {
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
* @return {Token[]}
*/
#resolveQueryTokens() {
const searchValue = this.#searchField.value;
@@ -60,69 +83,8 @@ export class SearchWrapper extends BaseComponent {
}
/**
* @param {KeyboardEvent} event
*/
#onSearchFieldKeyPressed(event) {
// On enter, attempt to replace the current active tag in the query with autocomplete selection
if (event.code === 'Enter') {
this.#onEnterPressed(event);
}
this.#autoCompleteField.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: event.keyCode
})
);
// Similarly to the site's autocomplete logic, we need to prevent the arrows up/down from causing any issues
if (event.keyCode === 38 || event.keyCode === 40) {
event.preventDefault();
}
}
/**
* @param {KeyboardEvent} event
*/
#onEnterPressed(event) {
const autocompleteSelection = document.querySelector('.autocomplete__item--selected');
if (!autocompleteSelection) {
return;
}
const activeToken = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (activeToken instanceof TermToken || activeToken instanceof QuotedTermToken) {
const selectionStart = activeToken.index;
const selectionEnd = activeToken.index + activeToken.value.length;
let autocompletedValue = autocompleteSelection.dataset.value;
if (activeToken instanceof QuotedTermToken) {
autocompletedValue = `"${QuotedTermToken.encode(autocompletedValue)}"`;
}
this.#searchField.value = this.#searchField.value.slice(0, selectionStart)
+ autocompletedValue
+ this.#searchField.value.slice(selectionEnd);
const newSelectionEnd = selectionStart + autocompletedValue.length;
// Place the caret at the end of the currently active tag.
// Actually, this does not work for some reason. After the tag is sent to the field and selection was changed to
// the end of the inserted tag, browser just does not scroll the input to the caret position.
this.#searchField.focus();
this.#searchField.setSelectionRange(newSelectionEnd, newSelectionEnd);
event.preventDefault();
}
}
/**
* @return {string|null}
* Find the currently selected term.
* @return {string|null} Selected term or null if none found.
*/
#findCurrentTagFragment() {
if (!this.#searchField) {
@@ -151,21 +113,45 @@ export class SearchWrapper extends BaseComponent {
return searchValue;
}
#emitAutoComplete(userInputFragment) {
this.#autoCompleteField.value = userInputFragment;
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param {string[]} suggestions List of suggestion to render the popup from.
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions, targetInput) {
/** @type {HTMLElement[]} */
const suggestedListItems = suggestions
.map(suggestedTerm => SearchWrapper.#renderTermSuggestion(suggestedTerm));
// Should be at least one frame away, since input event always removes autocomplete window
requestAnimationFrame(() => {
this.#autoCompleteField.dispatchEvent(
new InputEvent('input', {bubbles: true})
);
const autocompleteContainer = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer();
const autocompleteContainer = document.querySelector('.autocomplete');
if (autocompleteContainer) {
autocompleteContainer.style.left = `${this.container.offsetLeft}px`;
for (let existingTerm of autocompleteContainer.querySelectorAll('.autocomplete__item--property')) {
existingTerm.remove();
}
});
const listContainer = autocompleteContainer.querySelector('ul');
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
break;
case "end":
listContainer.append(...suggestedListItems);
break;
default:
console.warn("Invalid position for property suggestions!");
}
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
document.body.append(autocompleteContainer);
})
}
/**
@@ -179,8 +165,182 @@ export class SearchWrapper extends BaseComponent {
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
}
}
export function initializeSearWrapper(formElement) {
new SearchWrapper(formElement).initialize();
/**
* Regular expression to search the properties' syntax.
* @type {RegExp}
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param {string} searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue) {
/** @type {string[]} */
const suggestionsList = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
if (!parsedResult) {
return suggestionsList;
}
const propertyName = parsedResult.groups.name;
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups.value;
for (let candidateValue of this.#typeValues.get(propertyType)) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
}
}
return suggestionsList;
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups.op;
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
}
}
return suggestionsList;
}
// Otherwise, search for properties with names starting with the term
for (let [candidateProperty] of this.#properties) {
if (propertyName && !candidateProperty.startsWith(propertyName)) {
continue;
}
suggestionsList.push(candidateProperty);
}
return suggestionsList;
}
/**
* Render a new autocomplete container similar to the one generated by website. Might be sensitive to the updates
* made to the Philomena.
* @return {HTMLElement}
*/
static #renderAutocompleteContainer() {
const autocompleteContainer = document.createElement('div');
autocompleteContainer.className = 'autocomplete';
const innerListContainer = document.createElement('ul');
innerListContainer.className = 'autocomplete__list';
autocompleteContainer.append(innerListContainer);
return autocompleteContainer;
}
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param {string} suggestedTerm Term to use for suggestion item.
* @return {HTMLElement} Resulting element.
*/
static #renderTermSuggestion(suggestedTerm) {
/** @type {HTMLElement} */
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
suggestionItem.innerText = suggestedTerm;
suggestionItem.addEventListener('mouseover', () => {
this.#findAndResetSelectedSuggestion(suggestionItem);
suggestionItem.classList.add('autocomplete__item--selected');
});
suggestionItem.addEventListener('mouseout', () => {
this.#findAndResetSelectedSuggestion(suggestionItem);
})
return suggestionItem;
}
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
* search will be halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement) {
if (!suggestedElement.parentElement) {
return;
}
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
selectedElement.classList.remove('autocomplete__item--selected');
}
}
static #typeNumeric = Symbol();
static #typeDate = Symbol();
static #typeLiteral = Symbol();
static #typePersonal = Symbol();
static #properties = new Map([
['aspect_ratio', SearchWrapper.#typeNumeric],
['comment_count', SearchWrapper.#typeNumeric],
['created_at', SearchWrapper.#typeDate],
['description', SearchWrapper.#typeLiteral],
['downvotes', SearchWrapper.#typeNumeric],
['faved_by', SearchWrapper.#typeLiteral],
['faved_by_id', SearchWrapper.#typeNumeric],
['faves', SearchWrapper.#typeNumeric],
['first_seen_at', SearchWrapper.#typeDate],
['height', SearchWrapper.#typeNumeric],
['id', SearchWrapper.#typeNumeric],
['orig_sha512_hash', SearchWrapper.#typeLiteral],
['score', SearchWrapper.#typeNumeric],
['sha512_hash', SearchWrapper.#typeLiteral],
['source_url', SearchWrapper.#typeLiteral],
['tag_count', SearchWrapper.#typeNumeric],
['uploader', SearchWrapper.#typeLiteral],
['uploader_id', SearchWrapper.#typeNumeric],
['upvotes', SearchWrapper.#typeNumeric],
['width', SearchWrapper.#typeNumeric],
['wilson_score', SearchWrapper.#typeNumeric],
['my', SearchWrapper.#typePersonal],
]);
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
static #typeOperators = new Map([
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
]);
static #typeValues = new Map([
[SearchWrapper.#typePersonal, [
'comments',
'faves',
'posts',
'uploads',
'upvotes',
'watched',
]]
]);
}

View File

@@ -0,0 +1,191 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
*/
#dropdownContainer;
/**
* Button to add or remove the current tag into/from the active profile.
* @type {HTMLAnchorElement|null}
*/
#toggleOnExistingButton = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
* @type {HTMLAnchorElement|null}
*/
#addToNewButton = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
* @type {MaintenanceProfile|null}
*/
#activeProfile = null;
/**
* Is cursor currently entered the dropdown.
* @type {boolean}
*/
#isEntered = false;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
}
init() {
this.on('mouseenter', this.#onDropdownEntered.bind(this));
this.on('mouseleave', this.#onDropdownLeft.bind(this));
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
this.#activeProfile = activeProfileOrNull;
if (this.#isEntered) {
this.#updateButtons();
}
});
}
get #tagName() {
return this.container.dataset.tagName;
}
#onDropdownEntered() {
this.#isEntered = true;
this.#updateButtons();
}
#onDropdownLeft() {
this.#isEntered = false;
}
#updateButtons() {
if (!this.#activeProfile) {
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to new tagging profile',
this.#onAddToNewClicked.bind(this)
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
}
if (this.#activeProfile) {
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
'Add to existing tagging profile',
this.#onToggleInExistingClicked.bind(this)
);
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
if (this.#activeProfile.settings.tags.includes(this.#tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer.append(this.#toggleOnExistingButton);
}
return;
}
this.#toggleOnExistingButton?.remove();
}
async #onAddToNewClicked() {
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.#tagName]
});
await profile.save();
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
}
async #onToggleInExistingClicked() {
if (!this.#activeProfile) {
return;
}
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.#tagName;
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
tagsList.add(targetTagName);
}
this.#activeProfile.settings.tags = Array.from(tagsList.values());
await this.#activeProfile.save();
}
static #maintenanceSettings = new MaintenanceSettings();
/**
* Watch for changes to active profile.
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange) {
let lastActiveProfile;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(onActiveProfileChange);
});
MaintenanceProfile.subscribe(profiles => {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile);
});
this.#maintenanceSettings
.resolveActiveProfileAsObject()
.then(activeProfile => {
lastActiveProfile = activeProfile?.id ?? null;
onActiveProfileChange(activeProfile);
});
}
/**
* Create element for dropdown.
* @param {string} text Base text for the option.
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
* @return {HTMLAnchorElement}
*/
static #createDropdownLink(text, onClickHandler) {
/** @type {HTMLAnchorElement} */
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
dropdownLink.className = 'tag__dropdown__link';
dropdownLink.addEventListener('click', event => {
event.preventDefault();
onClickHandler(event);
});
return dropdownLink;
}
}
export function wrapTagDropdown(element) {
new TagDropdownWrapper(element).initialize();
}

View File

@@ -0,0 +1,81 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
export class TagsForm extends BaseComponent {
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll('.tag');
for (let tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName);
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return {Map<string, string>}
*/
#gatherTagCategories() {
/** @type {Map<string, string>} */
const tagCategories = new Map();
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
/** @type {TagsForm|null} */
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
tagEditor.refreshTagColors();
});
}
}

View File

@@ -1,4 +1,4 @@
import StorageHelper from "$lib/chrome/StorageHelper.js";
import StorageHelper from "$lib/browser/StorageHelper.js";
export default class ConfigurationController {
/** @type {string} */
@@ -79,4 +79,4 @@ export default class ConfigurationController {
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}
}

View File

@@ -1,4 +1,4 @@
import StorageHelper from "$lib/chrome/StorageHelper.js";
import StorageHelper from "$lib/browser/StorageHelper.js";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
@@ -90,4 +90,4 @@ export default class EntitiesController {
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
}
}
}

View File

@@ -0,0 +1,79 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
export default class CacheableSettings {
/** @type {ConfigurationController} */
#controller;
/** @type {Map<string, any>} */
#cachedValues = new Map();
/** @type {function[]} */
#disposables = [];
constructor(settingsNamespace) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(key, settings[key]);
}
})
);
}
/**
* @template SettingType
* @param {string} settingName
* @param {SettingType} defaultValue
* @return {Promise<SettingType>}
* @protected
*/
async _resolveSetting(settingName, defaultValue) {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName, defaultValue);
this.#cachedValues.set(settingName, settingValue);
return settingValue;
}
/**
* @param {string} settingName Name of the setting to write.
* @param {*} value Value to pass.
* @param {boolean} [force=false] Ignore the cache and force the update.
* @return {Promise<void>}
* @protected
*/
async _writeSetting(settingName, value, force = false) {
if (
!force
&& this.#cachedValues.has(settingName)
&& this.#cachedValues.get(settingName) === value
) {
return;
}
return this.#controller.writeSetting(settingName, value);
}
/**
* Subscribe to the changes made to the storage.
* @param {function(Object): void} callback Callback which will receive list of settings.
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback) {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback);
this.#disposables.push(unsubscribeCallback);
return unsubscribeCallback;
}
dispose() {
for (let disposeCallback of this.#disposables) {
disposeCallback();
}
}
}

View File

@@ -1,5 +1,6 @@
import StorageEntity from "$lib/extension/base/StorageEntity.js";
import EntitiesController from "$lib/extension/EntitiesController.js";
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
/**
* @typedef {Object} MaintenanceProfileSettings
@@ -29,6 +30,26 @@ class MaintenanceProfile extends StorageEntity {
return super.settings;
}
/**
* Export the profile to the formatted JSON.
*
* @type {string}
*/
toJSON() {
return JSON.stringify({
v: 1,
id: this.id,
name: this.settings.name,
tags: this.settings.tags,
}, null, 2);
}
toCompressedJSON() {
return compressToEncodedURIComponent(
this.toJSON()
);
}
static _entityName = "profiles";
/**
@@ -58,6 +79,62 @@ class MaintenanceProfile extends StorageEntity {
callback
);
}
/**
* Validate and import the profile from the JSON.
* @param {string} exportedString JSON for profile.
* @return {MaintenanceProfile} Maintenance profile imported from the JSON. Note that profile is not automatically
* saved.
* @throws {Error} When version is unsupported or format is invalid.
*/
static importFromJSON(exportedString) {
let importedObject;
try {
importedObject = JSON.parse(exportedString);
} catch (e) {
// Error will be sent later, since empty string could be parsed as nothing without raising the error.
}
if (!importedObject) {
throw new Error('Invalid JSON!');
}
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
) {
throw new Error('Invalid profile format detected!');
}
return new MaintenanceProfile(
importedObject.id,
{
name: importedObject.name,
tags: importedObject.tags,
}
);
}
/**
* Validate and import the profile from the compressed JSON string.
* @param {string} compressedString
* @return {MaintenanceProfile}
* @throws {Error} When version is unsupported or format is invalid.
*/
static importFromCompressedJSON(compressedString) {
return this.importFromJSON(
decompressFromEncodedURIComponent(compressedString)
);
}
}
export default MaintenanceProfile;
export default MaintenanceProfile;

View File

@@ -1,21 +1,10 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MaintenanceSettings {
#isInitialized = false;
#activeProfileId = null;
export default class MaintenanceSettings extends CacheableSettings {
constructor() {
void this.#initializeSettings();
}
async #initializeSettings() {
MaintenanceSettings.#controller.subscribeToChanges(settings => {
this.#activeProfileId = settings.activeProfile || null;
});
this.#activeProfileId = await MaintenanceSettings.#controller.readSetting("activeProfile", null);
this.#isInitialized = true;
super("maintenance");
}
/**
@@ -24,18 +13,7 @@ export default class MaintenanceSettings {
* @return {Promise<string|null>}
*/
async resolveActiveProfileId() {
if (!this.#isInitialized && !this.#activeProfileId) {
this.#activeProfileId = await MaintenanceSettings.#controller.readSetting(
"activeProfile",
null
);
}
if (!this.#activeProfileId) {
return null;
}
return this.#activeProfileId;
return this._resolveSetting("activeProfile", null);
}
/**
@@ -59,31 +37,27 @@ export default class MaintenanceSettings {
* @return {Promise<void>}
*/
async setActiveProfileId(profileId) {
this.#activeProfileId = profileId;
await MaintenanceSettings.#controller.writeSetting("activeProfile", profileId);
await this._writeSetting("activeProfile", profileId);
}
/**
* Controller for interaction with the settings stored in the extension's storage.
*
* @type {ConfigurationController}
*/
static #controller = new ConfigurationController("maintenance");
/**
* Subscribe to the changes in the maintenance-related settings.
*
* @param {function({activeProfileId: string|null}): void} callback Callback to call when the settings change. The new settings are
* passed as an argument.
* @param {function(MaintenanceSettingsObject): void} callback Callback to call when the settings change. The new
* settings are passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
static subscribe(callback) {
return MaintenanceSettings.#controller.subscribeToChanges(settings => {
subscribe(callback) {
return super.subscribe(settings => {
callback({
activeProfileId: settings.activeProfile || null,
activeProfile: settings.activeProfile || null,
});
});
}
}
/**
* @typedef {Object} MaintenanceSettingsObject
* @property {string|null} activeProfile
*/

View File

@@ -0,0 +1,39 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MiscSettings extends CacheableSettings {
constructor() {
super("misc");
}
/**
* @return {Promise<boolean>}
*/
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
/**
* @param {boolean} isEnabled
* @return {Promise<void>}
*/
async setFullscreenViewerEnabled(isEnabled) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
/**
* @param {function(MiscSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(settings => {
callback({
fullscreenViewer: settings.fullscreenViewer ?? true,
})
});
}
}
/**
* @typedef {Object} MiscSettingsObject
* @property {boolean} fullscreenViewer
*/

View File

@@ -0,0 +1,42 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class SearchSettings extends CacheableSettings {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position) {
return this._writeSetting("suggestPropertiesPosition", position);
}
/**
* @param {function(SearchSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(rawSettings => {
callback({
suggestProperties: rawSettings.suggestProperties ?? false,
suggestPropertiesPosition: rawSettings.suggestPropertiesPosition ?? "start",
});
});
}
}
/**
* @typedef {Object} SearchSettingsObject
* @property {boolean} suggestProperties
* @property {"start"|"end"} suggestPropertiesPosition
*/

23
src/lib/utils.js Normal file
View File

@@ -0,0 +1,23 @@
/**
* Traverse and find the object using the key path.
* @param {Object} targetObject Target object to traverse into.
* @param {string[]} path Path of keys to traverse deep into the object.
* @return {Object|null} Resulting object or null if nothing found (or target entry is not an object.
*/
export function findDeepObject(targetObject, path) {
let result = targetObject;
for (let key of path) {
if (!result || typeof result !== 'object') {
return null;
}
result = result[key];
}
if (!result || typeof result !== "object") {
return null;
}
return result;
}

View File

@@ -2,6 +2,10 @@
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
</script>
<Header/>

View File

@@ -1,10 +1,11 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<hr>
<MenuLink href="/about">About</MenuLink>
<MenuItem href="/preferences">Preferences</MenuItem>
<MenuItem href="/about">About</MenuItem>
</Menu>

View File

@@ -1,10 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<hr>
</Menu>
<h1>
@@ -16,10 +16,10 @@
</p>
<Menu>
<hr>
<MenuLink icon="globe" href="https://furbooru.org" target="_blank">
<MenuItem icon="globe" href="https://furbooru.org" target="_blank">
Visit Furbooru
</MenuLink>
<MenuLink icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
</MenuItem>
<MenuItem icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
GitHub Repo
</MenuLink>
</MenuItem>
</Menu>

View File

@@ -0,0 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
</Menu>

View File

@@ -0,0 +1,46 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
let profiles = [];
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
function resetActiveProfile() {
$activeProfileStore = null;
}
/**
* @param {Event} event
*/
function enableSelectedProfile(event) {
const target = event.target;
if (target instanceof HTMLInputElement && target.checked) {
activeProfileStore.set(target.value);
}
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<MenuItem icon="plus" href="/features/maintenance/new/edit">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/features/maintenance/{profile.id}"
name="active-profile"
value="{profile.id}"
checked="{$activeProfileStore === profile.id}"
on:input={enableSelectedProfile}>
{profile.settings.name}
</MenuRadioItem>
{/each}
<hr>
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
</Menu>

View File

@@ -1,11 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {onDestroy} from "svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import ProfileView from "$components/maintenance/ProfileView.svelte";
const profileId = $page.params.id;
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default|null} */
@@ -23,7 +22,7 @@
profile = resolvedProfile;
} else {
console.warn(`Profile ${profileId} not found.`);
goto('/settings/maintenance');
goto('/features/maintenance');
}
}
@@ -39,48 +38,26 @@
</script>
<Menu>
<MenuLink href="/settings/maintenance" icon="arrow-left">Back</MenuLink>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each profile.settings.tags as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuLink icon="wrench" href="/settings/maintenance/{profileId}/edit">Edit Profile</MenuLink>
<MenuLink icon="tag" href="#" on:click={activateProfile}>
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
<MenuItem icon="tag" href="#" on:click={activateProfile}>
{#if isActiveProfile}
<span>Profile is Active</span>
{:else}
<span>Activate Profile</span>
{/if}
</MenuLink>
</MenuItem>
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
Export Profile
</MenuItem>
</Menu>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/web-components/TagsEditor.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
@@ -9,7 +9,6 @@
import {goto} from "$app/navigation";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import {onDestroy} from "svelte";
/** @type {string} */
let profileId = $page.params.id;
@@ -29,9 +28,9 @@
if (maybeExistingProfile) {
targetProfile = maybeExistingProfile;
profileName = targetProfile.settings.name;
tagsList = [...targetProfile.settings.tags];
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
} else {
goto('/settings/maintenance');
goto('/features/maintenance');
}
}
@@ -45,7 +44,7 @@
targetProfile.settings.tags = [...tagsList];
await targetProfile.save();
await goto('/settings/maintenance/' + targetProfile.id);
await goto('/features/maintenance/' + targetProfile.id);
}
async function deleteProfile() {
@@ -55,14 +54,14 @@
}
await targetProfile.delete();
await goto('/settings/maintenance');
await goto('/features/maintenance');
}
</script>
<Menu>
<MenuLink icon="arrow-left" href="/settings/maintenance{profileId === 'new' ? '' : '/' + profileId}">
<MenuItem icon="arrow-left" href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}">
Back
</MenuLink>
</MenuItem>
<hr>
</Menu>
<FormContainer>
@@ -75,8 +74,8 @@
</FormContainer>
<Menu>
<hr>
<MenuLink href="#" on:click={saveProfile}>Save Profile</MenuLink>
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
{#if profileId !== 'new'}
<MenuLink href="#" on:click={deleteProfile}>Delete Profile</MenuLink>
<MenuItem href="#" on:click={deleteProfile}>Delete Profile</MenuItem>
{/if}
</Menu>

View File

@@ -0,0 +1,53 @@
<script>
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
const profileId = $page.params.id;
/**
* @type {import('$lib/extension/entities/MaintenanceProfile.js').default|undefined}
*/
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
/** @type {string} */
let exportedProfile = '';
/** @type {string} */
let compressedProfile = '';
if (!profile) {
goto('/features/maintenance/');
} else {
exportedProfile = profile.toJSON();
compressedProfile = profile.toCompressedJSON();
}
let isCompressedProfileShown = true;
</script>
<Menu>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{isCompressedProfileShown ? compressedProfile : exportedProfile}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={() => isCompressedProfileShown = !isCompressedProfileShown}>
Switch Format:
{#if isCompressedProfileShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>

View File

@@ -0,0 +1,131 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/maintenance/ProfileView.svelte";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import {goto} from "$app/navigation";
/** @type {string} */
let importedString = '';
/** @type {string} */
let errorMessage = '';
/** @type {MaintenanceProfile|null} */
let candidateProfile = null;
/** @type {MaintenanceProfile|null} */
let existingProfile = null;
function tryImportingProfile() {
candidateProfile = null;
existingProfile = null;
errorMessage = '';
importedString = importedString.trim();
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
try {
if (importedString.trim().startsWith('{')) {
candidateProfile = MaintenanceProfile.importFromJSON(importedString);
}
candidateProfile = MaintenanceProfile.importFromCompressedJSON(importedString);
} catch (error) {
errorMessage = error instanceof Error
? error.message
: 'Unknown error';
}
if (candidateProfile) {
existingProfile = $maintenanceProfilesStore.find(profile => profile.id === candidateProfile?.id) ?? null;
}
}
function saveProfile() {
if (!candidateProfile) {
return;
}
candidateProfile.save().then(() => {
goto(`/features/maintenance`);
});
}
function cloneAndSaveProfile() {
if (!candidateProfile) {
return;
}
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/maintenance`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/maintenance">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
{/if}
{#if !candidateProfile}
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={tryImportingProfile}>Import</MenuItem>
</Menu>
{:else}
{#if existingProfile}
<p class="warning">
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
</p>
{/if}
<ProfileView profile="{candidateProfile}"></ProfileView>
<Menu>
<hr>
{#if existingProfile}
<MenuItem on:click={saveProfile}>Replace Existing Profile</MenuItem>
<MenuItem on:click={cloneAndSaveProfile}>Save as New Profile</MenuItem>
{:else}
<MenuItem on:click={saveProfile}>Import New Profile</MenuItem>
{/if}
<MenuItem on:click={() => candidateProfile = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '../../../../styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -0,0 +1,13 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/search">Search</MenuItem>
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
<hr>
<MenuItem href="/preferences/debug">Debug</MenuItem>
</Menu>

View File

@@ -0,0 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
</Menu>

View File

@@ -0,0 +1,13 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import {storagesCollection} from "$stores/debug.js";
</script>
<Menu>
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
<hr>
{#each Object.keys($storagesCollection) as storageName}
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
{/each}
</Menu>

View File

@@ -0,0 +1,29 @@
<script>
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
let pathString = '';
/** @type {string[]} */
let pathArray = [];
/** @type {string|undefined} */
let storageName = void 0;
$: {
pathString = $page.params.path;
pathArray = pathString.length ? pathString.split("/") : [];
storageName = pathArray.shift()
if (pathArray.length && pathArray[pathArray.length - 1] === '') {
pathArray.pop();
}
if (!storageName) {
goto("/preferences/debug/storage");
}
}
</script>
{#if storageName}
<StorageViewer storage="{storageName}" path="{pathArray}"></StorageViewer>
{/if}

View File

@@ -0,0 +1,20 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import {fullScreenViewerEnabled} from "$stores/misc-preferences.js";
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
Enable fullscreen viewer button
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -0,0 +1,35 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import {
searchPropertiesSuggestionsEnabled,
searchPropertiesSuggestionsPosition
} from "$stores/search-preferences.js";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import SelectField from "$components/ui/forms/SelectField.svelte";
const propertiesPositions = {
start: "At the start of the list",
end: "At the end of the list",
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
Auto-complete properties
</CheckboxField>
</FormControl>
{#if $searchPropertiesSuggestionsEnabled}
<FormControl label="Show completed properties:">
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
options="{propertiesPositions}"></SelectField>
</FormControl>
{/if}
</FormContainer>

View File

@@ -1,10 +0,0 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
</script>
<Menu>
<MenuLink href="/">Back</MenuLink>
<hr>
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
</Menu>

View File

@@ -1,30 +0,0 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuLink from "$components/ui/menu/MenuLink.svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
let profiles = [];
$: profiles = $maintenanceProfilesStore.sort((a, b) => b.settings.name.localeCompare(a.settings.name));
function resetActiveProfile() {
$activeProfileStore = null;
}
</script>
<Menu>
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
<MenuLink icon="plus" href="/settings/maintenance/new/edit">Create New</MenuLink>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuLink href="/settings/maintenance/{profile.id}"
icon="{$activeProfileStore === profile.id ? 'tag' : null}">
{profile.settings.name}
</MenuLink>
{/each}
<hr>
<MenuLink href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuLink>
</Menu>

21
src/stores/debug.js Normal file
View File

@@ -0,0 +1,21 @@
import {writable} from "svelte/store";
/**
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.
* @type {Writable<Record<string, Object>>}
*/
export const storagesCollection = writable({});
chrome.storage.local.get(storages => {
storagesCollection.set(storages);
});
chrome.storage.local.onChanged.addListener(changes => {
storagesCollection.update(storages => {
for (let updatedStorageName of Object.keys(changes)) {
storages[updatedStorageName] = changes[updatedStorageName].newValue;
}
return storages;
})
});

View File

@@ -9,14 +9,6 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js"
*/
export const maintenanceProfilesStore = writable([]);
MaintenanceProfile.readAll().then(profiles => {
maintenanceProfilesStore.set(profiles);
});
MaintenanceProfile.subscribe(profiles => {
maintenanceProfilesStore.set(profiles);
});
/**
* Store for the active maintenance profile ID.
*
@@ -26,29 +18,40 @@ export const activeProfileStore = writable(null);
const maintenanceSettings = new MaintenanceSettings();
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
activeProfileStore.set(activeProfileId);
});
MaintenanceSettings.subscribe(settings => {
activeProfileStore.set(settings.activeProfileId || null);
});
/**
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
* @type {string|null}
*/
let lastActiveProfileId = null;
activeProfileStore.subscribe(profileId => {
if (profileId === lastActiveProfileId) {
return;
}
Promise.allSettled([
// Read the initial values from the storages first
MaintenanceProfile.readAll().then(profiles => {
maintenanceProfilesStore.set(profiles);
}),
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
activeProfileStore.set(activeProfileId);
})
]).then(() => {
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
MaintenanceProfile.subscribe(profiles => {
maintenanceProfilesStore.set(profiles);
});
lastActiveProfileId = profileId;
maintenanceSettings.subscribe(settings => {
activeProfileStore.set(settings.activeProfile || null);
});
void maintenanceSettings.setActiveProfileId(profileId);
});
// Watch the existence of the active profile on every change.
MaintenanceProfile.subscribe(profiles => {
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
activeProfileStore.set(null);
}
activeProfileStore.subscribe(profileId => {
lastActiveProfileId = profileId;
void maintenanceSettings.setActiveProfileId(profileId);
});
// Watch the existence of the active profile on every change.
MaintenanceProfile.subscribe(profiles => {
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
activeProfileStore.set(null);
}
});
});

View File

@@ -0,0 +1,14 @@
import {writable} from "svelte/store";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
export const fullScreenViewerEnabled = writable(true);
const miscSettings = new MiscSettings();
Promise.allSettled([
miscSettings.resolveFullscreenViewerEnabled().then(v => fullScreenViewerEnabled.set(v))
]).then(() => {
fullScreenViewerEnabled.subscribe(value => {
void miscSettings.setFullscreenViewerEnabled(value);
})
});

View File

@@ -0,0 +1,24 @@
import {writable} from "svelte/store";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
export const searchPropertiesSuggestionsEnabled = writable(false);
/** @type {import('svelte/store').Writable<"start"|"end">} */
export const searchPropertiesSuggestionsPosition = writable('start');
const searchSettings = new SearchSettings();
Promise.allSettled([
// First we wait for all properties to load and save
searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)),
searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v))
]).then(() => {
// And then we can start reading value changes from the writable objects
searchPropertiesSuggestionsEnabled.subscribe(value => {
void searchSettings.setPropertiesSuggestions(value);
});
searchPropertiesSuggestionsPosition.subscribe(value => {
void searchSettings.setPropertiesSuggestionsPosition(value);
});
})

View File

@@ -27,3 +27,8 @@ $tag-text: #4aa158;
$input-background: #26232d;
$input-border: #5c5a61;
$error-background: #7a2725;
$warning-background: #7d4825;
$warning-border: #95562c;

View File

@@ -1,11 +0,0 @@
.header__search--completable {
.search-autocomplete-dummy {
position: absolute;
height: 0;
opacity: 0;
pointer-events: none;
padding: 0;
margin: 0;
align-self: flex-end;
}
}

View File

@@ -151,7 +151,7 @@
display: block;
}
.media-box-show-fullscreen {
.media-box-show-fullscreen.is-visible {
display: block;
}
}
@@ -160,9 +160,9 @@
.fullscreen-viewer {
pointer-events: none;
z-index: 9999;
opacity: 0;
opacity: var(--opacity, 0);
background-color: black;
transition: opacity 0.1s;
transition: opacity 0.1s, transform 0.1s;
position: fixed;
left: 0;
right: 0;
@@ -171,6 +171,7 @@
display: flex;
justify-content: stretch;
align-items: stretch;
transform: translateY(var(--offset, 0));
img, video {
object-fit: contain;
@@ -179,7 +180,12 @@
}
&.shown {
opacity: 1;
opacity: var(--opacity, 1);
pointer-events: initial;
}
&.swiped {
opacity: var(--opacity, 1);
transition: none;
}
}

View File

@@ -36,4 +36,8 @@
.icon.icon-plus {
@include insert-icon('/img/plus.svg');
}
}
.icon.icon-file-export {
@include insert-icon('/img/file-export.svg');
}

View File

@@ -1,6 +1,6 @@
@use '../colors';
input {
input, textarea, select {
background: colors.$input-background;
border: 1px solid colors.$input-border;
color: colors.$text;
@@ -8,4 +8,8 @@ input {
font-family: monospace;
padding: 0 6px;
line-height: 26px;
}
}
select {
min-height: 28px;
}

View File

@@ -10,12 +10,21 @@
font-size: inherit;
}
body {
width: 320px;
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
&.is-in-tab {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
}
html, body {
background-color: colors.$background;
color: colors.$text;
font-size: 16px;
min-width: 320px;
max-height: min(100vh, 320px);
font-family: verdana, arial, helvetica, sans-serif;
margin: 0;
padding: 0;

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1zM192 336v-32c0-8.84 7.16-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.84 0-16-7.16-16-16zm379.05-28.02l-95.7-96.43c-10.06-10.14-27.36-3.01-27.36 11.27V288H384v64h63.99v65.18c0 14.28 17.29 21.41 27.36 11.27l95.7-96.42c6.6-6.66 6.6-17.4 0-24.05z"/>
</svg>

After

Width:  |  Height:  |  Size: 492 B