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:
7052
package-lock.json
generated
7052
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
11
src/app.d.ts
vendored
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
src/components/maintenance/ProfileView.svelte
Normal file
34
src/components/maintenance/ProfileView.svelte
Normal 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>
|
||||
@@ -12,5 +12,14 @@
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.control {
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,4 +42,5 @@
|
||||
{/each}
|
||||
<hr>
|
||||
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
|
||||
<MenuItem href="/settings/maintenance/import">Import Profile</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
src/routes/settings/maintenance/[id]/export/+page.svelte
Normal file
53
src/routes/settings/maintenance/[id]/export/+page.svelte
Normal 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>
|
||||
131
src/routes/settings/maintenance/import/+page.svelte
Normal file
131
src/routes/settings/maintenance/import/+page.svelte
Normal 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>
|
||||
@@ -27,3 +27,8 @@ $tag-text: #4aa158;
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
|
||||
$error-background: #7a2725;
|
||||
|
||||
$warning-background: #7d4825;
|
||||
$warning-border: #95562c;
|
||||
|
||||
@@ -36,4 +36,8 @@
|
||||
|
||||
.icon.icon-plus {
|
||||
@include insert-icon('/img/plus.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.icon.icon-file-export {
|
||||
@include insert-icon('/img/file-export.svg');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
3
static/img/file-export.svg
Normal file
3
static/img/file-export.svg
Normal 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 |
Reference in New Issue
Block a user