diff --git a/.gitignore b/.gitignore
index e3b54977..4ce5a18d 100755
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,8 @@ config/*
!config/default.js
assets/js/config/
!assets/js/config/default.js
-export*
+/export*
+/stashes*
*.heapprofile
*.heapsnapshot
.vscode
diff --git a/assets/components/actors/tile.vue b/assets/components/actors/tile.vue
index 7949141a..399b7d34 100755
--- a/assets/components/actors/tile.vue
+++ b/assets/components/actors/tile.vue
@@ -67,21 +67,21 @@
v-show="(!stash || stash.primary) && favorited"
icon="heart7"
class="stash stashed"
- @click.prevent.native="unstashActor"
+ @click.stop.native="unstashActor"
/>
diff --git a/assets/components/header/menu.vue b/assets/components/header/menu.vue
index 3e1ce93f..a4591360 100755
--- a/assets/components/header/menu.vue
+++ b/assets/components/header/menu.vue
@@ -9,7 +9,7 @@
diff --git a/migrations/20230607231459_stash_unique.js b/migrations/20230607231459_stash_unique.js
new file mode 100644
index 00000000..91088418
--- /dev/null
+++ b/migrations/20230607231459_stash_unique.js
@@ -0,0 +1,19 @@
+exports.up = async (knex) => {
+ await knex.schema.alterTable('stashes', (table) => {
+ table.unique(['user_id', 'slug']);
+ });
+
+ await knex.raw(`
+ CREATE UNIQUE INDEX unique_primary ON stashes (user_id, "primary") WHERE ("primary" = TRUE);
+ `);
+};
+
+exports.down = async (knex) => {
+ await knex.schema.alterTable('stashes', (table) => {
+ table.dropUnique(['user_id', 'slug']);
+ });
+
+ await knex.raw(`
+ DROP INDEX unique_primary;
+ `);
+};
diff --git a/src/tools/stashes-load.js b/src/tools/stashes-load.js
new file mode 100644
index 00000000..db8ce48c
--- /dev/null
+++ b/src/tools/stashes-load.js
@@ -0,0 +1,133 @@
+const fs = require('fs');
+const knex = require('../knex');
+const args = require('../argv');
+
+async function getStashId(stash, user) {
+ const existingStash = await knex('stashes')
+ .select('id')
+ .where('user_id', user.id)
+ .where((builder) => {
+ builder
+ .where('slug', stash.slug)
+ .orWhere('primary', stash.primary);
+ })
+ .first();
+
+ if (existingStash) {
+ return existingStash.id;
+ }
+
+ const [stashId] = await knex('stashes')
+ .insert({
+ user_id: user.id,
+ name: stash.name,
+ slug: stash.slug,
+ public: stash.public,
+ created_at: stash.createdAt,
+ })
+ .returning('id');
+
+ return stashId;
+}
+
+async function importReleases(type, stash, user, filename) {
+ const curatedType = type === 'release' ? 'scene' : type;
+
+ await stash[`${curatedType}s`].reduce(async (chain, scene) => {
+ await chain;
+
+ const release = await knex(`${type}s`)
+ .select(`${type}s.id`, 'entities.id as entity_id', 'entities.name as entity_name')
+ .leftJoin('entities', 'entities.id', `${type}s.entity_id`)
+ .where(`${type}s.entry_id`, scene.entryId)
+ .where('entities.slug', scene.entitySlug)
+ .where('entities.type', scene.entityType)
+ .first();
+
+ if (!release) {
+ throw new Error(`${curatedType.slice(0, 1).toUpperCase}${curatedType.slice(1)} ${scene.title} in ${scene.entityType} ${scene.entitySlug} does not exist`);
+ }
+
+ await knex(`stashes_${curatedType}s`)
+ .insert({
+ stash_id: stash.id,
+ [`${curatedType}_id`]: release.id,
+ comment: `import ${filename}`,
+ created_at: scene.createdAt,
+ })
+ .onConflict(['stash_id', `${curatedType}_id`])
+ .ignore();
+
+ console.log(`Imported ${stash.username} stash ${release.entity_name} ${curatedType} "${scene.title}"`);
+ }, Promise.resolve());
+}
+
+async function importActors(stash, user, filename) {
+ await stash.actors.reduce(async (chain, actor) => {
+ await chain;
+
+ const actorEntry = await knex('actors')
+ .select('actors.*')
+ .leftJoin('entities', 'entities.id', 'actors.entity_id')
+ .where('actors.slug', actor.slug)
+ .where((builder) => {
+ if (actor.entitySlug) {
+ builder
+ .where('entities.slug', actor.entitySlug)
+ .where('entities.type', actor.entityType);
+ }
+ })
+ .first();
+
+ if (!actorEntry) {
+ throw new Error(`Actor ${actor.slug} in ${user.username} stash ${stash.name} does not exist`);
+ }
+
+ await knex('stashes_actors')
+ .insert({
+ stash_id: stash.id,
+ actor_id: actorEntry.id,
+ comment: `import ${filename}`,
+ created_at: actor.createdAt,
+ })
+ .onConflict(['stash_id', 'actor_id'])
+ .ignore();
+
+ console.log(`Imported ${stash.username} stash actor "${actorEntry.name}"`);
+ }, Promise.resolve());
+}
+
+async function load() {
+ const filename = process.argv[2];
+ const file = await fs.promises.readFile(filename, 'utf8');
+
+ const stashes = file.split('\n')
+ .filter(Boolean)
+ .map((data) => JSON.parse(data))
+ .filter((stash) => !args.username || stash.username === args.username);
+
+ await stashes.reduce(async (stashChain, stash, index) => {
+ await stashChain;
+
+ const user = await knex('users')
+ .select('id')
+ .where('username', stash.username)
+ .first();
+
+ if (!user) {
+ throw new Error(`No user '${stash.username}'`);
+ }
+
+ const stashId = await getStashId(stash, user);
+
+ await importReleases('release', { ...stash, id: stashId }, user, filename);
+ await importReleases('movie', { ...stash, id: stashId }, user, filename);
+ await importActors({ ...stash, id: stashId }, user, filename);
+
+ console.log(`Imported ${index + 1}/${stashes.length} stash ${stash.name} from ${stash.username}`);
+ }, Promise.resolve());
+
+ process.exit();
+}
+
+load();
diff --git a/src/tools/stashes-save.js b/src/tools/stashes-save.js
new file mode 100644
index 00000000..c10921e3
--- /dev/null
+++ b/src/tools/stashes-save.js
@@ -0,0 +1,84 @@
+'use strict';
+
+// const config = require('config');
+// const util = require('util');
+// const path = require('path');
+
+const fs = require('fs');
+const moment = require('moment');
+
+const knex = require('../knex');
+
+async function save() {
+ const stashes = await knex('stashes')
+ .select('stashes.*', 'users.username')
+ .leftJoin('users', 'users.id', 'stashes.user_id');
+
+ const filename = `stashes-${moment().format('YYYY-MM-DD_hh_mm')}.json`;
+ let savedStashes = 0;
+
+ await stashes.reduce(async (chain, stash) => {
+ await chain;
+
+ const scenes = await knex('stashes_scenes')
+ .select('releases.title', 'releases.created_at', 'releases.entry_id', 'entities.slug as entity_slug', 'entities.type as entity_type')
+ .leftJoin('releases', 'releases.id', 'stashes_scenes.scene_id')
+ .leftJoin('entities', 'entities.id', 'releases.entity_id')
+ .where('stashes_scenes.stash_id', stash.id);
+
+ const movies = await knex('stashes_movies')
+ .select('movies.title', 'movies.created_at', 'movies.entry_id', 'entities.slug as entity_slug', 'entities.type as entity_type')
+ .leftJoin('movies', 'movies.id', 'stashes_movies.movie_id')
+ .leftJoin('entities', 'entities.id', 'movies.entity_id')
+ .where('stashes_movies.stash_id', stash.id);
+
+ const actors = await knex('stashes_actors')
+ .select('actors.slug', 'actors.created_at', 'entities.slug as entity_slug', 'entities.type as entity_type')
+ .leftJoin('actors', 'actors.id', 'stashes_actors.actor_id')
+ .leftJoin('entities', 'entities.id', 'actors.entity_id')
+ .where('stashes_actors.stash_id', stash.id);
+
+ console.log('scenes', scenes);
+ console.log('movies', movies);
+ console.log('actors', actors);
+
+ const curatedStash = JSON.stringify({
+ username: stash.username,
+ name: stash.name,
+ slug: stash.slug,
+ public: stash.public,
+ primary: stash.primary,
+ createdAt: stash.created_at,
+ scenes: scenes.map((scene) => ({
+ title: scene.title,
+ entryId: scene.entry_id,
+ entitySlug: scene.entity_slug,
+ entityType: scene.entity_type,
+ createdAt: scene.created_at,
+ })),
+ movies: movies.map((movie) => ({
+ title: movie.title,
+ entryId: movie.entry_id,
+ entitySlug: movie.entity_slug,
+ entityType: movie.entity_type,
+ createdAt: movie.created_at,
+ })),
+ actors: actors.map((actor) => ({
+ slug: actor.slug,
+ entitySlug: actor.entity_slug,
+ entityType: actor.entity_type,
+ createdAt: actor.created_at,
+ })),
+ });
+
+ await fs.promises.appendFile(filename, `${curatedStash}\n`);
+
+ savedStashes += 1;
+ }, Promise.resolve([]));
+
+ console.log(`Saved ${savedStashes} stashes`);
+
+ process.exit();
+}
+
+save();