diff --git a/electron/app.vue b/electron/app.vue index 625182b..b9d7e82 100644 --- a/electron/app.vue +++ b/electron/app.vue @@ -97,15 +97,14 @@ - -
- -
+
+
+
- - {{ tag.name }} - -
- + data-state="inactive" + class="flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background font-semibold text-center"> + + {{ tag.name }} + +
+ +
+ + + +
- - - - -
-
-
+
+
+ @@ -160,6 +155,7 @@ const tags = useState('appTags'); const applied_tags = ref([]); const search_term = ref(''); + const entries = useState('appEntries'); async function openDialog(): Promise { return await window.ipcRenderer.invoke('app-open-file-dialog'); @@ -201,6 +197,7 @@ image: HTMLImageElement ): TagStackImageData { return { + id: -1, url, width: image.width, height: image.height, @@ -228,6 +225,15 @@ const new_image_data: TagStackImageData[] = await Promise.all( files.map(processImage) ); + + for (const image of new_image_data) { + const exists = await entryExists(image); + + if (!exists) await insertEntry(image); + else image.id = exists; + } + + entries.value = await fetchEntries(); image_data.value = new_image_data; } catch (error) { console.error( @@ -237,66 +243,162 @@ loading.value = false; } } - function selectImage(image: TagStackImageData) { + async function selectImage(image: TagStackImageData) { selected_image.value = image; + + if (!image) return; + + const entry = entries.value.find((entry) => entry.id == image.id); + + if (!entry?.fields) return; + + const tag_ids: number[] = + (entry.fields as { [key: string]: any })['tag_id'] ?? []; + const tag_map: Map = new Map( + tags.value.map((tag) => [tag.id, tag]) + ); + applied_tags.value = tag_ids + .filter((tag) => tag_map.has(tag)) + .map((tag) => tag_map.get(tag) as Tag); } function getExtension(url: string): string { return url.split('.').pop()!.toUpperCase(); } - function handleTagSelect(tag: Tag) { + async function handleTagSelect(tag: Tag) { search_term.value = ''; - if (!applied_tags.value.includes(tag)) applied_tags.value.push(tag); + + if (!applied_tags.value.includes(tag)) { + applied_tags.value.push(tag); + if (selected_image.value) + await insertTagIntoImage(selected_image.value.id, tag.id); + } } - onMounted(async () => { - const tags_temp = [ - { - id: 0, - name: 'Archived', - aliases: ['Archive'], - color: 'RED' - }, - { - id: 1, - name: 'Favorite', - aliases: ['Favorited', 'Favorites'], - color: 'YELLOW' - }, - { - id: 1000, - name: 'Deferred Rendering', - shorthand: 'dr', - aliases: ['shaders'], - color: 'MINT' - } - ]; - await window.ipcRenderer.invoke( + function handleTagDelete(tag: Tag) { + applied_tags.value.splice(applied_tags.value.indexOf(tag), 1); + + if (selected_image.value) + removeTagFromImage(selected_image.value.id, tag.id); + } + async function insertEntry(entry: TagStackImageData) { + try { + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'INSERT INTO entries (filename, path) VALUES (?, ?)', + [entry.filename, entry.directory] + ); + const result = await window.ipcRenderer.invoke( + 'sqlite-operations', + 'get', + 'SELECT MAX(id) as id FROM entries' + ); + entry.id = result.id; + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'INSERT INTO fields (entry_id, tag_id) VALUES (?, ?)', + [entry.id, 1] + ); + } catch (error) { + console.error('Error during insertEntry:', error); + throw new Error('Failed to insert entry.'); + } + } + async function entryExists( + entry: TagStackImageData + ): Promise { + const result = await window.ipcRenderer.invoke( 'sqlite-operations', - 'run', - 'CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT, shorthand TEXT, color TEXT)' + 'get', + 'SELECT id FROM entries WHERE filename = ? AND path = ?', + [entry.filename, entry.directory] + ); + + return result !== null && result !== undefined ? result.id : null; + } + async function getTagsFromImage(image: TagStackImageData): Promise { + return await window.ipcRenderer.invoke( + 'sqlite-operations', + 'all', + 'SELECT tags.* FROM tags JOIN fields ON tags.id = fields.tag_id JOIN entries ON fields.entry_id = entries.id WHERE entries.filename = ?', + [image.filename] ); + } + async function removeTagFromImage(id: number, tag: number) { + try { + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'DELETE FROM fields WHERE entry_id = ? AND tag_id = ?', + [id, tag] + ); + entries.value = await fetchEntries(); + } catch (error) { + console.error('Error removing tag from image:', error); + throw new Error('Failed to remove tag from image.'); + } + } + async function insertTagIntoImage(id: number, tag: number) { await window.ipcRenderer.invoke( 'sqlite-operations', 'run', - 'CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, tag_id INTEGER, alias TEXT, UNIQUE (tag_id, alias), FOREIGN KEY (tag_id) REFERENCES tags (id))' + 'INSERT INTO fields (entry_id, tag_id) VALUES (?, ?)', + [id, tag] ); - - for (const tag of tags_temp) { + entries.value = await fetchEntries(); + } + onMounted(async () => { + await callOnce(async () => { + const default_tags = [ + { + id: 0, + name: 'Archived', + aliases: ['Archive'], + color: 'RED' + }, + { + id: 1, + name: 'Favorite', + aliases: ['Favorited', 'Favorites'], + color: 'YELLOW' + } + ]; await window.ipcRenderer.invoke( 'sqlite-operations', 'run', - 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES (?, ?, ?, ?)', - [tag.id, tag.name, tag.shorthand || null, tag.color] + 'CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT, shorthand TEXT, color TEXT)' ); - - for (const alias of tag.aliases) + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, tag_id INTEGER, alias TEXT, UNIQUE (tag_id, alias), FOREIGN KEY (tag_id) REFERENCES tags (id))' + ); + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'CREATE TABLE IF NOT EXISTS entries (id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, path TEXT NOT NULL)' + ); + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'CREATE TABLE IF NOT EXISTS fields (entry_id INTEGER, tag_id INTEGER, FOREIGN KEY (entry_id) REFERENCES entries (id))' + ); + for (const tag of default_tags) { await window.ipcRenderer.invoke( 'sqlite-operations', 'run', - 'INSERT OR IGNORE INTO aliases (tag_id, alias) VALUES (?, ?)', - [tag.id, alias] + 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES (?, ?, ?, ?)', + [tag.id, tag.name, null, tag.color] ); - } - await callOnce(async () => { + + for (const alias of tag.aliases) + await window.ipcRenderer.invoke( + 'sqlite-operations', + 'run', + 'INSERT OR IGNORE INTO aliases (tag_id, alias) VALUES (?, ?)', + [tag.id, alias] + ); + } tags.value = await fetchTags(); }); }); diff --git a/electron/composables/fetch_entries.ts b/electron/composables/fetch_entries.ts new file mode 100644 index 0000000..34d0ae7 --- /dev/null +++ b/electron/composables/fetch_entries.ts @@ -0,0 +1,16 @@ +const entries = ref([]); + +export async function fetchEntries(): Promise { + const path = await window.ipcRenderer.invoke('get-user-data-path'); + const fetchedEntries: any = await $fetch('/api/fetch_entries', { + query: { data: path } + }); + entries.value = fetchedEntries.map((entry: Entry) => ({ + id: entry.id, + filename: entry.filename, + path: entry.path, + fields: entry.fields + })); + + return entries.value; +} diff --git a/electron/composables/types.ts b/electron/composables/types.ts index 97682ee..442c7b0 100644 --- a/electron/composables/types.ts +++ b/electron/composables/types.ts @@ -5,12 +5,24 @@ export interface Tag { color: string; } +export interface Entry { + id: number; + filename: string; + path: string; + fields?: Field[]; +} + +export interface Field { + [key: string]: number[]; +} + export interface FileData { file_path: string; file_size: string; } export interface TagStackImageData { + id: number; url: string; width: number; height: number; diff --git a/electron/server/api/fetch_entries.ts b/electron/server/api/fetch_entries.ts new file mode 100644 index 0000000..88d76c6 --- /dev/null +++ b/electron/server/api/fetch_entries.ts @@ -0,0 +1,68 @@ +import { defineEventHandler, getQuery } from 'h3'; +import sqlite3 from 'sqlite3'; +import path from 'path'; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + const directory: string = query.data as string; + const dbPath = path.join(directory, 'db', 'tags.db'); + const db = new sqlite3.Database(dbPath); + + return new Promise((resolve, reject) => { + const promises: Promise[] = []; + const output: any[] = []; + db.each( + 'SELECT * FROM entries', + (err: any, row: any) => { + if (err) reject(err); + else { + const entry = { ...row, fields: {} }; + const promise_field = new Promise( + (field_resolve, field_reject) => { + db.all( + 'SELECT tag_id FROM fields WHERE entry_id = ?', + [entry.id], + (err: any, fields: any[]) => { + if (err) field_reject(err); + else { + fields.forEach((field) => { + Object.keys(field).forEach( + (key) => { + if (!entry.fields[key]) + entry.fields[key] = []; + + entry.fields[key].push( + field[key] + ); + } + ); + }); + output.push(entry); + field_resolve(); + } + } + ); + } + ); + promises.push(promise_field); + } + }, + (err: any) => { + if (err) reject(err); + else + Promise.all(promises) + .then(() => { + resolve(output); // Resolve with the collected entries + }) + .catch(reject); + } + ); + db.close((error) => { + if (error) { + console.error( + `An error occurred while trying to fetch entries: ${error}` + ); + } + }); + }); +}); diff --git a/electron/server/api/fetch_tags.ts b/electron/server/api/fetch_tags.ts index 95680f0..bf817ee 100644 --- a/electron/server/api/fetch_tags.ts +++ b/electron/server/api/fetch_tags.ts @@ -17,11 +17,8 @@ export default defineEventHandler(async (event) => { db.each( 'SELECT * FROM tags', (err: any, row: any) => { - if (err) { - reject(err); - } else { - output.push(row); - } + if (err) reject(err); + else output.push(row); }, (err: any) => { if (err) reject(err); diff --git a/tauri/app.vue b/tauri/app.vue index 2735148..556452e 100644 --- a/tauri/app.vue +++ b/tauri/app.vue @@ -97,15 +97,14 @@ - -
- -
+
+
+
- - {{ tag.name }} - -
- + data-state="inactive" + class="flex h-6 items-center rounded bg-secondary data-[state=active]:ring-ring data-[state=active]:ring-2 data-[state=active]:ring-offset-2 ring-offset-background font-semibold text-center"> + + {{ tag.name }} + +
+ +
+ + + +
- - - - -
-
-
+
+
+ @@ -165,6 +160,7 @@ const tags = useState('appTags'); const applied_tags = ref([]); const search_term = ref(''); + const entries = useState('appEntries'); async function openDialog(): Promise { try { @@ -212,6 +208,7 @@ image: HTMLImageElement ): TagStackImageData { return { + id: -1, url, width: image.width, height: image.height, @@ -239,6 +236,15 @@ const new_image_data: TagStackImageData[] = await Promise.all( files.map(processImage) ); + + for (const image of new_image_data) { + const exists = await entryExists(image); + + if (!exists) await insertEntry(image); + else image.id = exists; + } + + entries.value = await fetchEntries(); image_data.value = new_image_data; } catch (error) { console.error( @@ -250,62 +256,170 @@ } function selectImage(image: TagStackImageData) { selected_image.value = image; + if (!image) return; + + const entry = entries.value.find((entry) => entry.id === image.id); + + if (!entry?.fields) return; + + const tag_ids: number[] = + (entry.fields as { [key: string]: any })['tag_id'] ?? []; + const tag_map: Map = new Map( + tags.value.map((tag) => [tag.id, tag]) + ); + applied_tags.value = tag_ids + .filter((tag) => tag_map.has(tag)) + .map((tag) => tag_map.get(tag) as Tag); } function getExtension(url: string): string { return url.split('.').pop()!.toUpperCase(); } - function handleTagSelect(tag: Tag) { + async function handleTagSelect(tag: Tag) { search_term.value = ''; - if (!applied_tags.value.includes(tag)) applied_tags.value.push(tag); + + if (!applied_tags.value.includes(tag)) { + applied_tags.value.push(tag); + if (selected_image.value) + await insertTagIntoImage(selected_image.value.id, tag.id); + } + } + function handleTagDelete(tag: Tag) { + applied_tags.value.splice(applied_tags.value.indexOf(tag), 1); + + if (selected_image.value) + removeTagFromImage(selected_image.value.id, tag.id); + } + async function insertEntry(entry: TagStackImageData) { + const db = await sql.default.load('sqlite:db/tags.db'); + + try { + await db.execute( + 'INSERT INTO entries (filename, path) VALUES ($1, $2)', + [entry.filename, entry.directory] + ); + const result: any[] = await db.select( + 'SELECT MAX(id) as id FROM entries', + [entry.filename, entry.directory] + ); + entry.id = result[0].id; + await db.execute( + 'INSERT INTO fields (entry_id, tag_id) VALUES ($1, $2)', + [entry.id, 1] + ); + } catch (error) { + console.error( + `An error occurred while trying to insert an entry: ${error}` + ); + throw new Error('Failed to insert entry.'); + } finally { + await db.close(); + } + } + async function entryExists( + entry: TagStackImageData + ): Promise { + const db = await sql.default.load('sqlite:db/tags.db'); + + try { + const result: any[] = await db.select( + 'SELECT id FROM entries WHERE filename = $1 AND path = $2', + [entry.filename, entry.directory] + ); + + return result.length !== 0 ? result[0].id : null; + } catch (error) { + console.error( + `An error occurred while trying to check if an entry exists: ${error}` + ); + throw new Error('Failed to check if entry exists.'); + } finally { + await db.close(); + } + } + async function removeTagFromImage(id: number, tag: number) { + const db = await sql.default.load('sqlite:db/tags.db'); + + try { + await db.execute( + 'DELETE FROM fields WHERE entry_id = $1 AND tag_id = $2', + [id, tag] + ); + entries.value = await fetchEntries(); + } catch (error) { + console.error('Error removing tag from image:', error); + throw new Error('Failed to remove tag from image.'); + } finally { + await db.close(); + } + } + async function insertTagIntoImage(id: number, tag: number) { + const db = await sql.default.load('sqlite:db/tags.db'); + + try { + await db.execute( + 'INSERT INTO fields (entry_id, tag_id) VALUES ($1, $2)', + [id, tag] + ); + entries.value = await fetchEntries(); + } catch (error) { + console.error('Error inserting tag into image:', error); + throw new Error('Failed to inserting tag into image.'); + } finally { + await db.close(); + } } onMounted(async () => { - const tags_temp = [ - { - id: 0, - name: 'Archived', - aliases: ['Archive'], - color: 'RED' - }, - { - id: 1, - name: 'Favorite', - aliases: ['Favorited', 'Favorites'], - color: 'YELLOW' - }, - { - id: 1000, - name: 'Deferred Rendering', - shorthand: 'dr', - aliases: ['shaders'], - color: 'MINT' - } - ]; - if (!(await fs.exists('db', { baseDir: path.BaseDirectory.AppData }))) - await fs.mkdir('db', { baseDir: path.BaseDirectory.AppData }); + await callOnce(async () => { + const default_tags = [ + { + id: 0, + name: 'Archived', + aliases: ['Archive'], + color: 'RED' + }, + { + id: 1, + name: 'Favorite', + aliases: ['Favorited', 'Favorites'], + color: 'YELLOW' + } + ]; - const db = await sql.default.load(`sqlite:db/tags.db`); - await db.execute( - 'CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT, shorthand TEXT, color TEXT)' - ); - await db.execute( - 'CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, tag_id INTEGER, alias TEXT, UNIQUE (tag_id, alias), FOREIGN KEY (tag_id) REFERENCES tags (id))' - ); + if ( + !(await fs.exists('db', { + baseDir: path.BaseDirectory.AppData + })) + ) + await fs.mkdir('db', { baseDir: path.BaseDirectory.AppData }); - for (const tag of tags_temp) { + const db = await sql.default.load(`sqlite:db/tags.db`); + await db.execute( + 'CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT, shorthand TEXT, color TEXT)' + ); await db.execute( - 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES ($1, $2, $3, $4)', - [tag.id, tag.name, tag.shorthand || null, tag.color] + 'CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, tag_id INTEGER, alias TEXT, UNIQUE (tag_id, alias), FOREIGN KEY (tag_id) REFERENCES tags (id))' + ); + await db.execute( + 'CREATE TABLE IF NOT EXISTS entries (id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, path TEXT NOT NULL)' + ); + await db.execute( + 'CREATE TABLE IF NOT EXISTS fields (entry_id INTEGER, tag_id INTEGER, FOREIGN KEY (entry_id) REFERENCES entries (id))' ); - for (const alias of tag.aliases) + for (const tag of default_tags) { await db.execute( - 'INSERT OR IGNORE INTO aliases (tag_id, alias) VALUES ($1, $2)', - [tag.id, alias] + 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES ($1, $2, $3, $4)', + [tag.id, tag.name, null, tag.color] ); - } - await db.close(); - await callOnce(async () => { + for (const alias of tag.aliases) + await db.execute( + 'INSERT OR IGNORE INTO aliases (tag_id, alias) VALUES ($1, $2)', + [tag.id, alias] + ); + } + await db.close(); + tags.value = await fetchTags(); }); }); diff --git a/tauri/components/CreateTag.vue b/tauri/components/CreateTag.vue index 449c0ff..9fbbb27 100644 --- a/tauri/components/CreateTag.vue +++ b/tauri/components/CreateTag.vue @@ -95,11 +95,12 @@ const db = await sql.default.load(`sqlite:db/tags.db`); try { - const result: any = await db.select( + const result: any[] = await db.select( 'SELECT MAX(id) as max_id FROM tags' ); + console.log(JSON.stringify(result)); - return result.max_id + 1; + return result[0].max_id + 1; } catch (error) { console.error(`Error getting next tag ID: ${error}`); throw new Error('Failed to get next tag ID'); @@ -114,13 +115,13 @@ try { await db.execute( - 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES (?, ?, ?, ?)', + 'INSERT OR REPLACE INTO tags (id, name, shorthand, color) VALUES ($1, $2, $3, $4)', [id, tag.name, tag.shorthand || null, tag.color] ); if (tag.aliases) for (const alias of tag.aliases) await db.execute( - 'INSERT OR REPLACE INTO tag_aliases (tag_id, alias) VALUES (?, ?)', + 'INSERT OR REPLACE INTO tag_aliases (tag_id, alias) VALUES ($1, $2)', [id, alias] ); emit('create-tag-submit'); diff --git a/tauri/composables/fetch_entries.ts b/tauri/composables/fetch_entries.ts new file mode 100644 index 0000000..23aa285 --- /dev/null +++ b/tauri/composables/fetch_entries.ts @@ -0,0 +1,44 @@ +import * as sql from '@tauri-apps/plugin-sql'; + +const entries = ref([]); + +export async function fetchEntries(): Promise { + const db = await sql.default.load('sqlite:db/tags.db'); + + try { + const fetchedEntries: Entry[] = []; + const result: any[] = await db.select('SELECT * FROM entries'); + + for (const row of result) { + const entry = { ...row, fields: {} }; + const fields: any[] = await db.select( + 'SELECT tag_id FROM fields WHERE entry_id = $1', + [entry.id] + ); + fields.forEach((field) => { + Object.keys(field).forEach((key) => { + if (!entry.fields[key]) entry.fields[key] = []; + + entry.fields[key].push(field[key]); + }); + }); + fetchedEntries.push(entry); + } + + entries.value = fetchedEntries.map((entry: Entry) => ({ + id: entry.id, + filename: entry.filename, + path: entry.path, + fields: entry.fields + })); + + return entries.value; + } catch (error) { + console.error( + `An error occurred while trying to fetch entries: ${error}` + ); + throw new Error('Failed to fetch entries'); + } finally { + await db.close(); + } +} diff --git a/tauri/composables/fetch_tags.ts b/tauri/composables/fetch_tags.ts index dc12846..015f731 100644 --- a/tauri/composables/fetch_tags.ts +++ b/tauri/composables/fetch_tags.ts @@ -6,7 +6,7 @@ export async function fetchTags(): Promise { const db = await sql.default.load('sqlite:db/tags.db'); try { - const result: any = await db.select('SELECT * FROM tags'); + const result: any[] = await db.select('SELECT * FROM tags'); tags.value = result.map((tag: Tag) => ({ id: tag.id, name: tag.name, diff --git a/tauri/composables/types.ts b/tauri/composables/types.ts index c15fdac..d24f142 100644 --- a/tauri/composables/types.ts +++ b/tauri/composables/types.ts @@ -5,12 +5,24 @@ export interface Tag { color: string; } +export interface Entry { + id: number; + filename: string; + path: string; + fields?: Field[]; +} + +export interface Field { + [key: string]: number[]; +} + export interface FileData { file_path: string; file_size: string; } export interface TagStackImageData { + id: number; url: string; width: number; height: number;