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

Merge pull request #16 from koloml/release/0.2

Release: v0.2.0
This commit is contained in:
2024-08-08 07:29:54 +04:00
committed by GitHub
45 changed files with 4735 additions and 3688 deletions

View File

@@ -1,4 +1,26 @@
# Furbooru Tagging Assistant
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,7 @@
{
"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.2",
"version": "0.2.0",
"icons": {
"16": "icon16.png",
"48": "icon48.png",
@@ -26,6 +26,14 @@
"css": [
"src/styles/content/listing.scss"
]
},
{
"matches": [
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.js"
]
}
],
"action": {

7052
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.2",
"version": "0.2.0",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
@@ -22,5 +22,8 @@
"typescript": "^5.0.0",
"vite": "^5.0.3"
},
"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

@@ -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,34 @@
<script>
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default} */
export let profile;
</script>
<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>
<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>

7
src/content/header.js Normal file
View File

@@ -0,0 +1,7 @@
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
const siteHeader = document.querySelector('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);
}

View File

@@ -272,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()

View File

@@ -0,0 +1,346 @@
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 {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
#searchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
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;
});
}
/**
* 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,
this.#searchField.selectionEnd
);
}
/**
* 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;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
return this.#cachedParsedQuery;
}
this.#lastParsedSearchValue = searchValue;
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
return this.#cachedParsedQuery;
}
/**
* Find the currently selected term.
* @return {string|null} Selected term or null if none found.
*/
#findCurrentTagFragment() {
if (!this.#searchField) {
return null;
}
let searchValue = this.#searchField.value;
if (!searchValue) {
return null;
}
const token = SearchWrapper.#findActiveSearchTermPosition(
this.#resolveQueryTokens(),
this.#getInputUserSelection(),
);
if (token instanceof TermToken) {
return token.value;
}
if (token instanceof QuotedTermToken) {
return token.decodedValue;
}
return searchValue;
}
/**
* 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));
requestAnimationFrame(() => {
const autocompleteContainer = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer();
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);
})
}
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param {Token[]} tokens Search value to find the actively selected term from.
* @param {number} userSelectionIndex The index of the user selection.
* @return {Token|null} Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
}
/**
* 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,23 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */
#searchWrapper = null;
build() {
const searchForm = this.container.querySelector('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
init() {
if (this.#searchWrapper) {
this.#searchWrapper.initialize();
}
}
}
export function initializeSiteHeader(siteHeaderElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

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,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
*/

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="/settings/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="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/search">Search</MenuItem>
</Menu>

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 +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 href="/">Back</MenuLink>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
<MenuItem href="/settings/maintenance">Tagging Profiles</MenuItem>
</Menu>

View File

@@ -1,6 +1,7 @@
<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 MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
@@ -11,20 +12,35 @@
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>
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
<MenuLink icon="plus" href="/settings/maintenance/new/edit">Create New</MenuLink>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<MenuItem icon="plus" href="/settings/maintenance/new/edit">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuLink href="/settings/maintenance/{profile.id}"
icon="{$activeProfileStore === profile.id ? 'tag' : null}">
<MenuRadioItem href="/settings/maintenance/{profile.id}"
name="active-profile"
value="{profile.id}"
checked="{$activeProfileStore === profile.id}"
on:input={enableSelectedProfile}>
{profile.settings.name}
</MenuLink>
</MenuRadioItem>
{/each}
<hr>
<MenuLink href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuLink>
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/settings/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} */
@@ -39,48 +38,26 @@
</script>
<Menu>
<MenuLink href="/settings/maintenance" icon="arrow-left">Back</MenuLink>
<MenuItem href="/settings/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="/settings/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="/settings/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;
@@ -60,9 +59,9 @@
</script>
<Menu>
<MenuLink icon="arrow-left" href="/settings/maintenance{profileId === 'new' ? '' : '/' + profileId}">
<MenuItem icon="arrow-left" href="/settings/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('/settings/maintenance/');
} else {
exportedProfile = profile.toJSON();
compressedProfile = profile.toCompressedJSON();
}
let isCompressedProfileShown = true;
</script>
<Menu>
<MenuItem href="/settings/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(`/settings/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(`/settings/maintenance`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/settings/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

@@ -30,17 +30,17 @@ maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
activeProfileStore.set(activeProfileId);
});
MaintenanceSettings.subscribe(settings => {
activeProfileStore.set(settings.activeProfileId || null);
maintenanceSettings.subscribe(settings => {
activeProfileStore.set(settings.activeProfile || 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;
}
lastActiveProfileId = profileId;
void maintenanceSettings.setActiveProfileId(profileId);

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

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