1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-24 07:12:57 +00:00

Merge pull request #17 from koloml/feature/tagging-profiles-import-export

Added functionality to export and import tagging profiles
This commit is contained in:
2024-07-07 19:45:07 +04:00
committed by GitHub
15 changed files with 3879 additions and 3551 deletions

7052
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -12,5 +12,14 @@
</label>
<style lang="scss">
.label {
margin-bottom: .5em;
}
.control {
:global(textarea) {
width: 100%;
resize: vertical;
}
}
</style>

View File

@@ -5,12 +5,12 @@
export let href = null;
/**
* @type {"tag"|"paint-brush"|"arrow-left"|"info-circle"|"wrench"|"globe"|"plus"|null}
* @type {App.IconName|null}
*/
export let icon = null;
/**
* @type {"_blank"|"_self"|"_parent"|"_top"|undefined}
* @type {App.LinkTarget|undefined}
*/
export let target = undefined;
</script>
@@ -28,6 +28,8 @@
.menu-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
i {
width: 16px;

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

@@ -42,4 +42,5 @@
{/each}
<hr>
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/settings/maintenance/import">Import Profile</MenuItem>
</Menu>

View File

@@ -4,6 +4,7 @@
import {page} from "$app/stores";
import {goto} from "$app/navigation";
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} */
@@ -41,18 +42,7 @@
<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>
@@ -64,21 +54,10 @@
<span>Activate Profile</span>
{/if}
</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

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

@@ -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 {
background: colors.$input-background;
border: 1px solid colors.$input-border;
color: colors.$text;
@@ -8,4 +8,4 @@ input {
font-family: monospace;
padding: 0 6px;
line-height: 26px;
}
}

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