Giter VIP home page Giter VIP logo

tagstudio's Introduction

TagStudio (Alpha): A User-Focused Document Management System

Caution

This is still a very rough personal project of mine in its infancy. I’m open-sourcing it now in order to accept contributors sooner and to better facilitate the direction of the project from an earlier stage. There are bugs, and there will very likely be breaking changes!

TagStudio is a photo & file organization application with an underlying system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.

TagStudio Screenshot

TagStudio Alpha v9.1.0 running on Windows 10.

Contents

Goals

  • To achieve a portable, privacy-oriented, open, extensible, and feature-rich system of organizing and rediscovering files.
  • To provide powerful methods for organization, notably the concept of tag composition, or “taggable tags”.
  • To create an implementation of such a system that is resilient against a user’s actions outside the program (modifying, moving, or renaming files) while also not burdening the user with mandatory sidecar files or otherwise requiring them to change their existing file structures and workflows.
  • To support a wide range of users spanning across different platforms, multi-user setups, and those with large (several terabyte) libraries.
  • To make the darn thing look like nice, too. It’s 2024, not 1994.

Priorities

  1. The concept. Even if TagStudio as a project or application fails, I’d hope that the idea lives on in a superior project. The goals outlined above don’t reference TagStudio once - TagStudio is what references the goals.
  2. The system. Frontends and implementations can vary, as they should. The core underlying metadata management system is what should be interoperable between different frontends, programs, and operating systems. A standard implementation for this should settle as development continues. This opens up the doors for improved and varied clients, integration with third-party applications, and more.
  3. The application. If nothing else, TagStudio the application serves as the first (and so far only) implementation for this system of metadata management. This has the responsibility of doing the idea justice and showing just what’s possible when it comes to user file management.
  4. (The name.) I think it’s fine for an app or client, but it doesn’t really make sense for a system or standard. I suppose this will evolve with time.

Current Features

  • Create libraries/vaults centered around a system directory. Libraries contain a series of entries: the representations of your files combined with metadata fields. Each entry represents a file in your library’s directory, and is linked to its location.
  • Add metadata to your library entries, including:
    • Name, Author, Artist (Single-Line Text Fields)
    • Description, Notes (Multiline Text Fields)
    • Tags, Meta Tags, Content Tags (Tag Boxes)
  • Create rich tags composed of a name, a list of aliases, and a list of “subtags” - being tags in which these tags inherit values from.
  • Search for entries based on tags, metadata (TBA), or filenames/filetypes (using filename: <query>)
  • Special search conditions for entries that are: untagged/no tags and empty/no fields.

Note

For more information on the project itself, please see the FAQ section as well as the documentation.

Contributing

If you're interested in contributing to TagStudio, please take a look at the contribution guidelines for how to get started!

Installation

To download TagStudio, visit the Releases section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for Windows, macOS (Apple Silicon & Intel), and Linux. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.

Important

On macOS, you may be met with a message saying ""TagStudio" can't be opened because Apple cannot check it for malicious software." If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says ""TagStudio" was blocked from use because it is not from an identified developer." Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.

Optional Arguments

Optional arguments to pass to the program.

--open <path> / -o <path> Path to a TagStudio Library folder to open on start.

--config-file <path> / -c <path> Path to the TagStudio config file to load.

Usage

Creating/Opening a Library

With TagStudio opened, start by creating a new library or opening an existing one using File -> Open/Create Library from the menu bar. TagStudio will automatically create a new library from the chosen directory if one does not already exist. Upon creating a new library, TagStudio will automatically scan your folders for files and add those to your library (no files are moved during this process!).

Refreshing the Library

In order to scan for new files or file changes, you’ll need to manually go to File -> Refresh Directories.

Note

In the future, library refreshing will also be automatically done in the background, or additionally on app startup.

Adding Metadata to Entries

To add a metadata field to a file entry, start by clicking the “Add Field” button under the file preview in the right-hand preview panel. From the dropdown menu, select the type of metadata field you’d like to add to the entry.

Editing Metadata Fields

Text Line / Text Box

Hover over the field and click the pencil icon. From there, add or edit text in the dialog box popup.

Tag Box

Click the “+” button at the end of the Tags list, and search for tags to add inside the new dialog popup. Click the “+” button next to whichever tags you want to add. Alternatively, after you search for a tag, press the Enter/Return key to add the add the first item in the list. Press Enter/Return once more to close the dialog box

Warning

Keyboard control and navigation is currently very buggy, but will be improved in future versions.

Creating Tags

To create a new tag, click on Edit -> New Tag from the menu bar. From there, enter a tag name, shorthand name, any tag aliases separated by newlines, any subtags, and an optional color.

  • The tag shorthand is a type of alias that displays in situations when screen space is more valuable (ex. as a subtag for other tags).
  • Aliases are alternate names for a tag. These let you search for terms other than the exact tag name in order to find the tag again.
  • Subtags are tags in which this tag is a child tag of. In other words, tags under this section are parents of this tag. For example, if you had a tag for a character from a show, you would make the show a subtag of this character. This would display as “Character (Show)” in most areas of the app. The first tag in this list is used as the tag shown in parentheses for specification.
  • The color dropdown lets you select an optional color for this tag to display as.

Editing Tags

To edit a tag, right-click the tag in the tag field of the preview pane and select “Edit Tag”

Relinking Renamed/Moved Files

Inevitably, some of the files inside your library will be renamed, moved, or deleted. If a file has been renamed or moved, TagStudio will display the thumbnail as a red tag with a cross through it (this icon is also used for items with broken thumbnails). To relink moved files or delete these entries, go to Tools -> Manage Unlinked Entries. Click the “Refresh” button to scan your library for unlinked entries. Once complete, you can attempt to “Search & Relink” any unlinked entries to their respective files, or “Delete Unlinked Entries” in the event the original files have been deleted and you no longer wish to keep their metadata entries inside your library.

Warning

There is currently no method to relink entries to files that have been renamed - only moved or deleted. This is a top priority for future releases.

Warning

If multiple matches for a moved file are found (matches are currently defined as files with a matching filename as the original), TagStudio will currently ignore the match groups. Adding a GUI for manual selection, as well as smarter automated relinking, are top priorities for future versions.

Saving the Library

Libraries are saved upon exiting the program. To manually save, select File -> Save Library from the menu bar. To save a backup of your library, select File -> Save Library Backup from the menu bar.

Half-Implemented Features

Fix Duplicate Files

Load in a .dupeguru file generated by dupeGuru and mirror metadata across entries marked as duplicates. After mirroring, return to dupeGuru to manage deletion of the duplicate files. After deletion, use the “Fix Unlinked Entries” feature in TagStudio to delete the duplicate set of entries for the now-deleted files

Caution

While this feature is functional, it’s a pretty roundabout process and can be streamlined in the future.

Image Collage

Create an image collage of your photos and videos.

Caution

Collage sizes and options are hardcoded, and there's no GUI indicating the process of the collage creation.

Macros

Apply tags and other metadata automatically depending on certain criteria. Set specific macros to run when the files are added to the library. Part of this includes applying tags automatically based on parent folders.

Caution

Macro options are hardcoded, and there’s currently no way for the user to interface with this (still incomplete) system at all.

Gallery-dl Sidecar Importing

Import JSON sidecar data generated by gallery-dl.

Caution

This feature is not supported or documented in any official capacity whatsoever. It will likely be rolled-in to a larger and more generalized sidecar importing feature in the future.

Launching/Building From Source

See instructions in the "Creating Development Environment" section from the contribution documentation.

FAQ

What State Is the Project Currently In?

As of writing (Alpha v9.3.0) the project is in a useable state, however it lacks proper testing and quality of life features.

What Features Are You Planning on Adding?

Important

See the Planned Features documentation for the latest feature lists. The lists here are currently being migrated over there with individual pages for larger features.

Of the several features I have planned for the project, these are broken up into “priority” features and “future” features. Priority features were originally intended for the first public release, however are currently absent from the Alpha v9.x.x builds.

Priority Features

  • Improved search
    • Sortable Search
    • Boolean Search
    • Coexisting Text + Tag Search
    • Searchable File Metadata
  • Comprehensive Tag management tab
  • Easier ways to apply tags in bulk
    • Tag Search Panel
    • Recent Tags Panel
    • Top Tags Panel
    • Pinned Tags Panel
  • Better (stable, performant) library grid view
  • Improved entry relinking
  • Cached thumbnails
  • Tag-like Groups
  • Resizable thumbnail grid
  • User-defined metadata fields
  • Multiple directory support
  • SQLite (or similar) save files
  • Reading of EXIF and XMP fields
  • Improved UI/UX
  • Better internal API for accessing Entries, Tags, Fields, etc. from the library.
  • Proper testing workflow
  • Continued code cleanup and modularization
  • Exportable/importable library data including "Tag Packs"

Future Features

  • Support for multiple simultaneous users/clients
  • Draggable files outside the program
  • Comprehensive filetype whitelist
  • A finished “macro system” for automatic tagging based on predetermined criteria.
  • Different library views
  • Date and time fields
  • Entry linking/referencing
  • Audio waveform previews
  • 3D object previews
  • Additional previews for miscellaneous file types
  • Optional global tags and settings, spanning across libraries
  • Importing & exporting libraries to/from other programs
  • Port to a more performant language and modern frontend (Rust?, Tauri?, etc.)
  • Plugin system
  • Local OCR search
  • Support for local machine learning-based tag suggestions for images
  • Mobile version (FAR future)

Features I Likely Won’t Add/Pull

  • Native Cloud Integration
    • There are plenty of services already (native or third-party) that allow you to mount your cloud drives as virtual drives on your system. Pointing TagStudio to one of these mounts should function similarly to what native integration would look like.
  • Native ChatGPT/Non-Local LLM Integration
    • This could mean different things depending on what you're intending. Whether it's trying to use an LLM to replace the native search, or to trying to use a model for image recognition, I'm not interested in hooking people's TagStudio libraries into non-local LLMs such as ChatGPT and/or turn the program into a "chatbot" interface (see: Goals/Privacy). I wouldn't, however, mind using locally hosted models to provide the optional ability for additional searching and tagging methods (especially when it comes to facial recognition).

Why Is the Version Already v9?

I’ve been developing this project over several years in private, and have gone through several major iterations and rewrites in that time. This “major version” is just a number at the end of the day, and if I wanted to I couldn’t released this as “Version 0” or “Version 1.0”, but I’ve decided to stick to my original version numbers to avoid needing to go in and change existing documentation and code comments. Version 10 is intended to include all of the “Priority Features” I’ve outlined in the previous section. I’ve also labeled this version as an Alpha, and will likely reset the numbers when a feature-complete beta is reached.

Wait, Is There a CLI Version?

As of right now, no. However, I did have a CLI version in the recent past before dedicating my efforts to the Qt GUI version. I’ve left in the currently-inoperable CLI code just in case anyone was curious about it. Also yes, it’s just a bunch of glorified print statements (the outlook for some form of curses on Windows didn’t look great at the time, and I just needed a driver for the newly refactored code...).

tagstudio's People

Contributors

0xnim avatar abby-freakazoid avatar chao-master avatar cirillom avatar creepler13 avatar cyanvoxel avatar dakota-marshall avatar drretro2033 avatar eltociear avatar gabrieljreed avatar gawidev avatar hidorikun avatar icosahunter avatar jingubangwest avatar lennartcode avatar loran425 avatar michaelmegrath avatar niwazukihon avatar olemortensen8 avatar pencilvoid avatar possiblepanda avatar samuellieberman avatar seakrueger avatar sylviask avatar techcraftergaming avatar thesacraft avatar williamtcastro avatar xarvex avatar yedpodtrzitko avatar yoylonerd avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

tagstudio's Issues

[suggestion] Lightbox for viewing media

Pardon me if I missed it from readme.md

Basically a way of viewing media inside the program. Opening them in full window (not exactly fullscreen) and having some buttons to control what's shown - next file, previous file, start slideshow, open random file etc.
It can also have keyboard shortcuts for some actions.

[BUG] Visual display issues with empty content.

I believe it’s not supposed to look like this normally. I’m not sure if I caused the error myself…

In the image below, you can see that the empty fields are not loading correctly, this also happened when there was nothing in the library. Maybe it’s an idea not to display them, then if the whole area is empty, you could display a specific text like ‘please open a library’ or something like that.

image

Handle SIGINT/SIGTERM

Or: Ctrl-C

The program doesn't appear to be responsive to it when running it in console and debugging, adding a signal trap handler to trigger program exit would help with that

[suggestion] Conditional (view) Statements

REcently I reset my windows system and started fresh. This always brings new possiblities to try other/new things and there's nothing newer than an alpha project which just released. Anyways, I'm currently in the process of using TagStudio as my main form of storing and searching for files, but I've come over an "inconvenience". A very minor one at that, however I still got this idea from it:

"Conditional Statements" would be a system where you can specify what should happen to an entry if it has a certain tag. The main point, imo, would be to remove certain entries from being viewed, except if you explicitly search for them. The reasoning for this is, I have a few files that I don't want people to see when I'd stream or similar. I tagged them with "Personal" and it would be really cool if it were possible to hide such entries.

This might sound very insignificant... and probably is, but I'm sure one could extend that system to also have other abilities which I could not have imagined.

Audio Browsing Quality Of Life

In order for this program to be powerful for browsing audio samples in particular. One should be able to see the waveform preview and click on a part of the wave form to hear a preview.

(The video I have attached is of FL Studio's built in file explorer, but this feature is standard in Ableton and many other DAW's and video editing software.)

Every time I Shift+Click on the waveform, it plays the file from where i just clicked.

I like to search through sample library's, and many of them are completely unorganized and have 8+ samples stored in each audio file. So I don't really know what's in the file until I've opened it up and listened to the whole thing, which is a headache on windows file explorer, because you need to open up VLC or media player to listen to it.

Recently I have been experimenting with using alternative DAW's for an alternative workflow experience, but using file explorer for browsing samples sucks. So I just end up having FL Studio open anyway, which defeats the purpose of trying to take a break from FL Studio.

The ability to tag and browse samples and preview specific sections of them would be incredibly powerful for any musician and video editor.

FL.Studio.Sample.Browser.mov

Adding a cancel button to the creating Library Loading Bar

Hi,
I think it would be useful if you added a cancel button in the "Refreshing Directories" Window, that pops up after creating a new Library. Because at this point of time a miss selection of a whole drive as a new Library, can't be canceled (at least i have not found a way to do it) and it would be tedious to restart the whole application. Iit would also be an option to add this functionality to the closing button of the popup.

[BUG] visual bootup thing

It probably is only on linux but i'd still like to mention it.
Linux works fine just need a boot script into virtual environment.

there's a boot issue that i'm not sure is only a linux thing or also in windows.:
it looks like this first time i booted it up is that normal:
Screenshot_20240423_065150

otherwise if it's not I'd not mind writing bootup script for linux.
And the UI 'glitch' if it's not suposed to look like that. minding that it only does it until files are added
Screenshot_20240423_064855

[BUG] Max Pathlength exceeding files

When Library.refresh_dir is called and one file in the searched files exceeds the MAX_PATH length(256 chars) the programm throws a FileNotFoundError. You could circumvent this by checking the path length of every path or you catch the exception and discard the refresh. This all happens while you sort the files that are not in the library, in library.py in line 982-983. This is really an edge case but can happen when long File names and nested Folders are used. I don't know if you can repreduce this error because as far as i now you are asked by the python installer if you wan't to disable this limit(this is not selected by default I think). I ran into this issue while exploring what would happen if i selected my C: drive, maybe this helps to repruduce this bug.

Tag Merging

Let's say you have tag "atsuko kagari" and "akko", both are the same character, but one is a nickname for the other. You've tagged some files with Akko and some others with the full name, but finally want to merge them together under a single tag.

akko can become an alias for the full name, but then all existing files with that tag need to be processed with the following:

  • add the new tag
  • remove the old tag
  • add all parent-child relationships from the old tag to the new one
  • remove all parent-child relationships from the old one

doing this with a single modal would be helpful :)

[CRITICAL BUG] Multi-Editing Asymmetrical Fields Causing Library Corruption

System Details

System: Windows 10 22H2
TagStudio Version: ff488da

Describe the Problem

By multi-editing multiple library entries that have asymmetrical fields (ex. one entry has [tag_field, text_line] and another has just a matching [text_line], if the [text_line] were to be edited, the program will try to store the data in the same field index for both, causing the library to corrupt.

Steps to Reproduce

  1. Create any "tag" field on an empty entry. Then create a title field, call it “test”.
  2. Create title on a second empty entry, also call it “test”.
  3. Select both entries, from second to first.
  4. Edit title field, call it “test2”.
  5. Produces the error, with errors persisting when trying to browse the library.
    In addition, by creating a different "text line" field in place of the "tag" field, you can visualize how the incorrect field for the first entry is modified with the new string data.
2024-04-25.18-33-03-1.mp4

[suggestion] Taggable Folders

TagStudio should enable direct folder tagging, enhancing its functionality significantly. This feature would enhance compatibility with project-centric environments such as Unity and Android Studio, where various files and subfolders need to be grouped yet might include junk files that don't require tagging.

This functionality could be implemented by introducing a .tsignore file within folders. The file would serve both as a marker for TagStudio to recognize the folder as taggable and as a mechanism for users to specify which contents should be ignored (like .gitignore). This approach would allow users to find projects and resources used in the projects that could be useful elsewhere

Fix Open In File Manager for Linux

The current implementation assumes the file manager is Nautilus. As a Linux fallback we can use "xdg-open" passing in the parent folder for the file in question, this won't select the file but it will open the folder the file is located in, in a file manager agnostic way. It looks like KDE Dolphin supports the "--select" option so we can add support for that too.
Also I recommend changing the tooltip from "open in explorer" to "open in file manager" or "open in file browser" so the language is platform agnostic.

Refactoring

the ts_qt.py file contains most if not all of the code for running TagStudio at this point, as new features are added this is causing this file to continue to grow and make things harder to find, edit and maintain.

I think breaking out the widgets and modals would be a good start on this

a barebones example would be the Preview Panel

class PreviewPanel(QWidget):
"""The Preview Panel Widget."""
tags_updated = Signal()
def __init__(self, library: Library, driver:'QtDriver'):
super().__init__()
self.lib = library
self.driver:QtDriver = driver
self.initialized = False
self.isOpen: bool = False
# self.filepath = None
# self.item = None # DEPRECATED, USE self.selected
self.common_fields = []
self.mixed_fields = []
self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items
self.tag_callback = None
self.containers: list[QWidget] = []
self.img_button_size: tuple[int, int] = (266, 266)
self.image_ratio: float = 1.0
root_layout = QHBoxLayout(self)
root_layout.setContentsMargins(0, 0, 0, 0)
self.image_container = QWidget()
image_layout = QHBoxLayout(self.image_container)
image_layout.setContentsMargins(0, 0, 0, 0)
splitter = QSplitter()
splitter.setOrientation(Qt.Orientation.Vertical)
splitter.setHandleWidth(12)
self.preview_img = QPushButton()
self.preview_img.setMinimumSize(*self.img_button_size)
self.preview_img.setFlat(True)
self.tr = ThumbRenderer()
self.tr.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
self.tr.updated_ratio.connect(lambda ratio: (self.set_image_ratio(ratio),
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()), ratio)))
splitter.splitterMoved.connect(lambda: self.update_image_size((self.image_container.size().width(), self.image_container.size().height())))
splitter.addWidget(self.image_container)
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
self.file_label = QLabel('Filename')
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.file_label.setStyleSheet('font-weight: bold; font-size: 12px')
self.dimensions_label = QLabel('Dimensions')
self.dimensions_label.setWordWrap(True)
# self.dim_label.setTextInteractionFlags(
# Qt.TextInteractionFlag.TextSelectableByMouse)
self.dimensions_label.setStyleSheet(ItemThumb.small_text_style)
# small_text_style = (
# f'background-color:rgba(17, 15, 27, 192);'
# f'font-family:Oxanium;'
# f'font-weight:bold;'
# f'font-size:12px;'
# f'border-radius:3px;'
# f'padding-top: 4px;'
# f'padding-right: 1px;'
# f'padding-bottom: 1px;'
# f'padding-left: 1px;'
# )
self.scroll_layout = QVBoxLayout()
self.scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_layout.setContentsMargins(6,1,6,6)
scroll_container: QWidget = QWidget()
scroll_container.setObjectName('entryScrollContainer')
scroll_container.setLayout(self.scroll_layout)
# scroll_container.setStyleSheet('background:#080716; border-radius:12px;')
scroll_container.setStyleSheet(
'background:#00000000;'
'border-style:none;'
f'QScrollBar::{{background:red;}}'
)
info_section = QWidget()
info_layout = QVBoxLayout(info_section)
info_layout.setContentsMargins(0,0,0,0)
info_layout.setSpacing(6)
self.setStyleSheet(
'background:#00000000;'
f'QScrollBar::{{background:red;}}'
)
scroll_area = QScrollArea()
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShadow(QFrame.Shadow.Plain)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setStyleSheet(
'background:#55000000;'
'border-radius:12px;'
'border-style:solid;'
'border-width:1px;'
'border-color:#11FFFFFF;'
# f'QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{border: none;background: none;}}'
# f'QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal, QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{border: none;background: none;color: none;}}'
f'QScrollBar::{{background:red;}}'
)
scroll_area.setWidget(scroll_container)
info_layout.addWidget(self.file_label)
info_layout.addWidget(self.dimensions_label)
info_layout.addWidget(scroll_area)
splitter.addWidget(info_section)
root_layout.addWidget(splitter)
splitter.setStretchFactor(1, 2)
self.afb_container = QWidget()
self.afb_layout = QVBoxLayout(self.afb_container)
self.afb_layout.setContentsMargins(0,12,0,0)
self.add_field_button = QPushButton()
self.add_field_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_field_button.setMinimumSize(96, 28)
self.add_field_button.setMaximumSize(96, 28)
self.add_field_button.setText('Add Field')
self.add_field_button.setStyleSheet(
f'QPushButton{{'
# f'background: #1E1A33;'
# f'color: #CDA7F7;'
f'font-weight: bold;'
# f"border-color: #2B2547;"
f'border-radius: 6px;'
f'border-style:solid;'
# f'border-width:{math.ceil(1*self.devicePixelRatio())}px;'
'background:#55000000;'
'border-width:1px;'
'border-color:#11FFFFFF;'
# f'padding-top: 1.5px;'
# f'padding-right: 4px;'
# f'padding-bottom: 5px;'
# f'padding-left: 4px;'
f'font-size: 13px;'
f'}}'
f'QPushButton::hover'
f'{{'
f'background: #333333;'
f'}}')
self.afb_layout.addWidget(self.add_field_button)
self.afm = AddFieldModal(self.lib)
self.place_add_field_button()
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()))
def resizeEvent(self, event: QResizeEvent) -> None:
self.update_image_size((self.image_container.size().width(), self.image_container.size().height()))
return super().resizeEvent(event)
def get_preview_size(self) -> tuple[int, int]:
return (self.image_container.size().width(), self.image_container.size().height())
def set_image_ratio(self, ratio:float):
# logging.info(f'Updating Ratio to: {ratio} #####################################################')
self.image_ratio = ratio
def update_image_size(self, size:tuple[int, int], ratio:float = None):
if ratio:
self.set_image_ratio(ratio)
# self.img_button_size = size
# logging.info(f'')
# self.preview_img.setMinimumSize(64,64)
adj_width = size[0]
adj_height = size[1]
# Landscape
if self.image_ratio > 1:
# logging.info('Landscape')
adj_height = size[0] * (1/self.image_ratio)
# Portrait
elif self.image_ratio <= 1:
# logging.info('Portrait')
adj_width = size[1] * self.image_ratio
if adj_width > size[0]:
adj_height = adj_height * (size[0]/adj_width)
adj_width = size[0]
elif adj_height > size[1]:
adj_width = adj_width * (size[1]/adj_height)
adj_height = size[1]
# adj_width = min(adj_width, self.image_container.size().width())
# adj_height = min(adj_width, self.image_container.size().height())
# self.preview_img.setMinimumSize(s)
# self.preview_img.setMaximumSize(s_max)
adj_size = QSize(adj_width, adj_height)
self.img_button_size = (adj_width, adj_height)
self.preview_img.setMaximumSize(adj_size)
self.preview_img.setIconSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
# if type(self.item) == Entry:
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
# self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio())
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
# logging.info(f' Max Button Size: {size}')
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
# logging.info(f'')
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
def place_add_field_button(self):
self.scroll_layout.addWidget(self.afb_container)
self.scroll_layout.setAlignment(self.afb_container, Qt.AlignmentFlag.AlignHCenter)
try:
self.afm.done.disconnect()
self.add_field_button.clicked.disconnect()
except RuntimeError:
pass
# self.afm.done.connect(lambda f: (self.lib.add_field_to_entry(self.selected[0][1], f), self.update_widgets()))
self.afm.done.connect(lambda f: (self.add_field_to_selected(f), self.update_widgets()))
self.add_field_button.clicked.connect(self.afm.show)
def add_field_to_selected(self, field_id: int):
"""Adds an entry field to one or more selected items."""
added = set()
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY and item_pair[1] not in added:
self.lib.add_field_to_entry(item_pair[1], field_id)
added.add(item_pair[1])
# def update_widgets(self, item: Union[Entry, Collation, Tag]):
def update_widgets(self):
"""
Renders the panel's widgets with the newest data from the Library.
"""
logging.info(f'[ENTRY PANEL] UPDATE WIDGETS ({self.driver.selected})' )
self.isOpen = True
# self.tag_callback = tag_callback if tag_callback else None
window_title = ''
# 0 Selected Items
if len(self.driver.selected) == 0:
if len(self.selected) != 0 or not self.initialized:
self.file_label.setText(f"No Items Selected")
self.dimensions_label.setText("")
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), '', (512, 512), ratio, True)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
for i, c in enumerate(self.containers):
c.setHidden(True)
self.selected = list(self.driver.selected)
self.add_field_button.setHidden(True)
# 1 Selected Item
elif len(self.driver.selected) == 1:
# 1 Selected Entry
if self.driver.selected[0][0] == ItemType.ENTRY:
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
# If a new selection is made, update the thumbnail and filepath.
if (len(self.selected) == 0
or self.selected != self.driver.selected):
filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
window_title = filepath
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), filepath, (512, 512), ratio)
self.file_label.setText("\u200b".join(filepath))
# TODO: Do this somewhere else, this is just here temporarily.
extension = os.path.splitext(filepath)[1][1:].lower()
try:
image = None
if extension in IMAGE_TYPES:
image = Image.open(filepath)
if image.mode == 'RGBA':
new_bg = Image.new('RGB', image.size, color='#222222')
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != 'RGB':
image = image.convert(mode='RGB')
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2))
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Stats for specific file types are displayed here.
if extension in (IMAGE_TYPES + VIDEO_TYPES):
self.dimensions_label.setText(f"{extension.upper()}{format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px")
else:
self.dimensions_label.setText(f"{extension.upper()}")
if not image:
self.dimensions_label.setText(f"{extension.upper()}{format_size(os.stat(filepath).st_size)}")
raise UnidentifiedImageError
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
pass
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.preview_img.clicked.connect(
lambda checked=False, filepath=filepath: open_file(filepath))
self.selected = list(self.driver.selected)
for i, f in enumerate(item.fields):
self.write_container(i, f)
# Hide leftover containers
if len(self.containers) > len(item.fields):
for i, c in enumerate(self.containers):
if i > (len(item.fields) - 1):
c.setHidden(True)
self.add_field_button.setHidden(False)
# 1 Selected Collation
elif self.driver.selected[0][0] == ItemType.COLLATION:
pass
# 1 Selected Tag
elif self.driver.selected[0][0] == ItemType.TAG_GROUP:
pass
# Multiple Selected Items
elif len(self.driver.selected) > 1:
if self.selected != self.driver.selected:
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.dimensions_label.setText("")
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), '', (512, 512), ratio, True)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
pass
self.common_fields = []
self.mixed_fields = []
for i, item_pair in enumerate(self.driver.selected):
if item_pair[0] == ItemType.ENTRY:
item = self.lib.get_entry(item_pair[1])
if i == 0:
for f in item.fields:
self.common_fields.append(f)
else:
common_to_remove = []
for f in self.common_fields:
# Common field found (Same ID, identical content)
if f not in item.fields:
common_to_remove.append(f)
# Mixed field found (Same ID, different content)
if self.lib.get_field_index_in_entry(item, self.lib.get_field_attr(f, 'id')):
# if self.lib.get_field_attr(f, 'type') == ('tag_box'):
# pass
# logging.info(f)
# logging.info(type(f))
f_stripped = {self.lib.get_field_attr(f, 'id'):None}
if f_stripped not in self.mixed_fields and (f not in self.common_fields or f in common_to_remove):
# and (f not in self.common_fields or f in common_to_remove)
self.mixed_fields.append(f_stripped)
self.common_fields = [f for f in self.common_fields if f not in common_to_remove]
order: list[int] = (
[0] +
[1, 2] +
[9, 17, 18, 19, 20] +
[8, 7, 6] +
[4] +
[3, 21] +
[10, 14, 11, 12, 13, 22] +
[5]
)
self.mixed_fields = sorted(self.mixed_fields, key=lambda x: order.index(self.lib.get_field_attr(x, 'id')))
self.selected = list(self.driver.selected)
for i, f in enumerate(self.common_fields):
logging.info(f'ci:{i}, f:{f}')
self.write_container(i, f)
for i, f in enumerate(self.mixed_fields, start = len(self.common_fields)):
logging.info(f'mi:{i}, f:{f}')
self.write_container(i, f, mixed=True)
# Hide leftover containers
if len(self.containers) > len(self.common_fields) + len(self.mixed_fields):
for i, c in enumerate(self.containers):
if i > (len(self.common_fields) + len(self.mixed_fields) - 1):
c.setHidden(True)
self.add_field_button.setHidden(False)
self.initialized = True
# # Uninitialized or New Item:
# if not self.item or self.item.id != item.id:
# # logging.info(f'Uninitialized or New Item ({item.id})')
# if type(item) == Entry:
# # New Entry: Render preview and update filename label
# filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
# window_title = filepath
# ratio: float = self.devicePixelRatio()
# self.tr.render_big(time.time(), filepath, (512, 512), ratio)
# self.file_label.setText("\u200b".join(filepath))
# # TODO: Deal with this later.
# # https://stackoverflow.com/questions/64252654/pyqt5-drag-and-drop-into-system-file-explorer-with-delayed-encoding
# # https://doc.qt.io/qtforpython-5/PySide2/QtCore/QMimeData.html#more
# # drag = QDrag(self.preview_img)
# # mime = QMimeData()
# # mime.setUrls([filepath])
# # drag.setMimeData(mime)
# # drag.exec_(Qt.DropAction.CopyAction)
# try:
# self.preview_img.clicked.disconnect()
# except RuntimeError:
# pass
# self.preview_img.clicked.connect(
# lambda checked=False, filepath=filepath: open_file(filepath))
# for i, f in enumerate(item.fields):
# self.write_container(item, i, f)
# self.item = item
# # try:
# # self.tags_updated.disconnect()
# # except RuntimeError:
# # pass
# # if self.tag_callback:
# # # logging.info(f'[UPDATE CONTAINER] Updating Callback for {item.id}: {self.tag_callback}')
# # self.tags_updated.connect(self.tag_callback)
# # Initialized, Updating:
# elif self.item and self.item.id == item.id:
# # logging.info(f'Initialized Item, Updating! ({item.id})')
# for i, f in enumerate(item.fields):
# self.write_container(item, i, f)
# # Hide leftover containers
# if len(self.containers) > len(self.item.fields):
# for i, c in enumerate(self.containers):
# if i > (len(self.item.fields) - 1):
# c.setHidden(True)
self.setWindowTitle(window_title)
self.show()
def set_tags_updated_slot(self, slot: object):
"""
Replacement for tag_callback.
"""
try:
self.tags_updated.disconnect()
except RuntimeError:
pass
logging.info(f'[UPDATE CONTAINER] Setting tags updated slot')
self.tags_updated.connect(slot)
# def write_container(self, item:Union[Entry, Collation, Tag], index, field):
def write_container(self, index, field, mixed=False):
"""Updates/Creates data for a FieldContainer."""
# logging.info(f'[ENTRY PANEL] WRITE CONTAINER')
# Remove 'Add Field' button from scroll_layout, to be re-added later.
self.scroll_layout.takeAt(self.scroll_layout.count()-1).widget()
container: FieldContainer = None
if len(self.containers) < (index + 1):
container = FieldContainer()
self.containers.append(container)
self.scroll_layout.addWidget(container)
else:
container = self.containers[index]
# container.inner_layout.removeItem(container.inner_layout.itemAt(1))
# container.setHidden(False)
if self.lib.get_field_attr(field, 'type') == 'tag_box':
# logging.info(f'WRITING TAGBOX FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Tag Box)"
if not mixed:
item = self.lib.get_entry(self.selected[0][1]) # TODO TODO TODO: TEMPORARY
if type(container.get_inner_widget()) == TagBoxWidget:
inner_container: TagBoxWidget = container.get_inner_widget()
inner_container.set_item(item)
inner_container.set_tags(self.lib.get_field_attr(field, 'content'))
try:
inner_container.updated.disconnect()
except RuntimeError:
pass
# inner_container.updated.connect(lambda f=self.filepath, i=item: self.write_container(item, index, field))
else:
inner_container = TagBoxWidget(item, title, index, self.lib, self.lib.get_field_attr(field, 'content'), self.driver)
container.set_inner_widget(inner_container)
inner_container.field = field
inner_container.updated.connect(lambda: (self.write_container(index, field), self.tags_updated.emit()))
# if type(item) == Entry:
# NOTE: Tag Boxes have no Edit Button (But will when you can convert field types)
# f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.set_copy_callback(None)
container.set_edit_callback(None)
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Tag Box)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
container.set_copy_callback(None)
container.set_edit_callback(None)
container.set_remove_callback(None)
self.tags_updated.emit()
# self.dynamic_widgets.append(inner_container)
elif self.lib.get_field_attr(field, 'type') in 'text_line':
# logging.info(f'WRITING TEXTLINE FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ''
if not mixed:
text = self.lib.get_field_attr(
field, 'content').replace('\r', '\n')
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Text Line)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
if not mixed:
modal = PanelModal(EditTextLine(self.lib.get_field_attr(field, 'content')),
title=title,
window_title=f'Edit {self.lib.get_field_attr(field, "name")}',
save_callback=(lambda content: (self.update_field(field, content), self.update_widgets()))
)
container.set_edit_callback(modal.show)
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.set_copy_callback(None)
else:
container.set_edit_callback(None)
container.set_copy_callback(None)
container.set_remove_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
elif self.lib.get_field_attr(field, 'type') in 'text_box':
# logging.info(f'WRITING TEXTBOX FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ''
if not mixed:
text = self.lib.get_field_attr(
field, 'content').replace('\r', '\n')
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Text Box)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
if not mixed:
container.set_copy_callback(None)
modal = PanelModal(EditTextBox(self.lib.get_field_attr(field, 'content')),
title=title,
window_title=f'Edit {self.lib.get_field_attr(field, "name")}',
save_callback=(lambda content: (self.update_field(field, content), self.update_widgets()))
)
container.set_edit_callback(modal.show)
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
else:
container.set_edit_callback(None)
container.set_copy_callback(None)
container.set_remove_callback(None)
elif self.lib.get_field_attr(field, 'type') == 'collation':
# logging.info(f'WRITING COLLATION FOR ITEM {item.id}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(True)
container.set_inline(False)
collation = self.lib.get_collation(self.lib.get_field_attr(field, 'content'))
title = f"{self.lib.get_field_attr(field, 'name')} (Collation)"
text: str = (f'{collation.title} ({len(collation.e_ids_and_pages)} Items)')
if len(self.selected) == 1:
text += f' - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}'
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
# if type(item) == Entry:
container.set_copy_callback(None)
# container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
elif self.lib.get_field_attr(field, 'type') == 'datetime':
# logging.info(f'WRITING DATETIME FOR ITEM {item.id}')
if not mixed:
try:
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
# TODO: Localize this and/or add preferences.
date = dt.strptime(self.lib.get_field_attr(
field, 'content'), '%Y-%m-%d %H:%M:%S')
title = f"{self.lib.get_field_attr(field, 'name')} (Date)"
inner_container = TextWidget(title, date.strftime('%D - %r'))
container.set_inner_widget(inner_container)
except:
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Date) (Unknown Format)"
inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content')))
# if type(item) == Entry:
container.set_copy_callback(None)
container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
else:
text = '<i>Mixed Data</i>'
title = f"{self.lib.get_field_attr(field, 'name')} (Wacky Date)"
inner_container = TextWidget(title, text)
container.set_inner_widget(inner_container)
container.set_copy_callback(None)
container.set_edit_callback(None)
container.set_remove_callback(None)
else:
# logging.info(f'[ENTRY PANEL] Unknown Type: {self.lib.get_field_attr(field, "type")}')
container.set_title(self.lib.get_field_attr(field, 'name'))
# container.set_editable(False)
container.set_inline(False)
title = f"{self.lib.get_field_attr(field, 'name')} (Unknown Field Type)"
inner_container = TextWidget(title, str(self.lib.get_field_attr(field, 'content')))
container.set_inner_widget(inner_container)
# if type(item) == Entry:
container.set_copy_callback(None)
container.set_edit_callback(None)
# container.set_remove_callback(lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets(item)))
prompt=f'Are you sure you want to remove this \"{self.lib.get_field_attr(field, "name")}\" field?'
callback = lambda: (self.remove_field(field), self.update_widgets())
# callback = lambda: (self.lib.get_entry(item.id).fields.pop(index), self.update_widgets())
container.set_remove_callback(lambda: self.remove_message_box(
prompt=prompt,
callback=callback))
container.setHidden(False)
self.place_add_field_button()
def remove_field(self, field:object):
"""Removes a field from all selected Entries, given a field object."""
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
try:
index = entry.fields.index(field)
updated_badges = False
if 8 in entry.fields[index].keys() and (1 in entry.fields[index][8] or 0 in entry.fields[index][8]):
updated_badges = True
# TODO: Create a proper Library/Entry method to manage fields.
entry.fields.pop(index)
if updated_badges:
self.driver.update_badges()
except ValueError:
logging.info(f'[PREVIEW PANEL][ERROR?] Tried to remove field from Entry ({entry.id}) that never had it')
pass
def update_field(self, field:object, content):
"""Removes a field from all selected Entries, given a field object."""
field = dict(field)
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY:
entry = self.lib.get_entry(item_pair[1])
try:
logging.info(field)
index = entry.fields.index(field)
self.lib.update_entry_field(entry.id, index, content, 'replace')
except ValueError:
logging.info(f'[PREVIEW PANEL][ERROR] Tried to update field from Entry ({entry.id}) that never had it')
pass
def remove_message_box(self, prompt:str, callback:FunctionType) -> int:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle('Remove Field')
remove_mb.setIcon(QMessageBox.Icon.Warning)
cancel_button = remove_mb.addButton('&Cancel', QMessageBox.ButtonRole.DestructiveRole)
remove_button = remove_mb.addButton('&Remove', QMessageBox.ButtonRole.RejectRole)
# remove_mb.setStandardButtons(QMessageBox.StandardButton.Cancel)
remove_mb.setDefaultButton(cancel_button)
result = remove_mb.exec_()
# logging.info(result)
if result == 1:
callback()

This widget is almost 700 lines long and has only been edited about 6 times with minor changes but the ts_qt.py file appears in almost half of the commits to the project so far.

Happy to start working on a PR for this but want there to be a little more feedback than my own thoughts.

All-in-one please-download-and-run-thank-you "easy mode" script

I'm seeing a lot of people in the discord asking for help with installation instructions, I think we should have a simple easy_run.bat script or the likes that would;

  1. Check the installed python version(s), pick a compatible one, or;
  • Install a compatible python version (maybe after "press Y to install python")
  1. Create a venv, and install dependencies inside of it, or just verify its there
  2. Launch the program

This script could be hailed with a CMD ./easy_run.bat, or a double-click.


This script should be entirely temporary to the alpha stage and should be removed once we start doing packaged releases (releases with .exe binaries/installs bundled with them).

Store file information in an SQLite database

The current JSON schema used takes up a fair amount of space for JSON data, and can take some time to process a large number of files. As well, it is prone to write corruption. SQLite will help both space and performance concerns, and we can do further mitigations to avoid the mentioned corruption.

[suggestion] custom fields

i don't know enough about the details of how tagstudio works to know how easy this would be, but the ability to make custom fields is a thing i've seen before and i think it'd be nice to have

essentially just the ability to make your own fields and give them their own type (text/number/date/list/etc)

[SUGGESTION] Ideas for folders (for compatibility)

I had the idea that there is still the normal folder tree view and that this is simply taken from the existing structure of the explorer from which the files come.
so you could also have automatic tags added based on the base folders.
or also the possibility that you can display the files again in the explorer etc
the other possibilities are, I think, limitless.

the idea would be to put it on the left side as a panel that you can easily hide at any time

[Suggestion] Bulk Tag Adding

Currently, it's possible to bulk add fields, like the actual Tag attribute to all selected items, however it is impossible to add the tags themselves. Currently having to go 1 by one in order to tag items.

macOS build instructions

I managed to build & run on macOS so I'm sharing:

It didn't work with the latest version of python, something related to dependencies, but 3.10 works so try with that.
I recommend using mise for installation of python, it can also handle the rest of the build. Using mise, this is how you build on macOS (and potentially linux)

Put this into a file called .mise.toml in the project directory

[tools]
python = "3.10"

[env]
_.python.venv = { path = ".venv", create = true }

[tasks.tagstudio]
run = "pip install -r requirements.txt && python tagstudio/tagstudio.py"

and then run (with installed mise) mise trust && mise install, this will install python and create the venv and you can run the normal linux/ macos build instruction, or alternatively use mise run --yes tagstudio one-liner

the app & gui starts but looks a little fucky

[SUGGESTION/BUG] Tags not visible when none are searched

when adding a tag to an image and not searching for a tag explicitly, no tags are shown at all, i feel like it would be a cool idea to list all tags made by default into the Tag search with the scroll bar(depending on the amount of tags there is) and just secluding tags that dont fit the search anymore. (i hope that makes sense haha)

image

[Suggestion] Allow selecting a windows library in when opening/creating TagStudio library

For Example if there are multiple users, they might have TagStudio installed for all of the users, or some may have opted out, but they still would want to access the files in the library in an organized way

Also an option: automatically create windows library for TagStudio that includes the folders of the main libraries(i.e. music, documents, photos and etc.)

[SUGGESTION] Tag overview

I had the idea earlier that you have a list or overview to display all available tags.
and at the end also the connections of the nested tags

If you want to take it to the extreme, you can even display the whole thing visually in a kind of graphic.

[SUGGESTION] Focus on Czkawka instead of dupeGuru for de-duplication

dupeGuru last released a version in July 2022, and its functionality has not advanced in some years. Czkawka last released a version in February 2024 and is being actively developed, with many features that dupeGuru lacks entirely. Czkawka also currently (as of 2024-04-23) has ~200 more forks and ~13000 more stars than dupeGuru, so it appears the community is much larger around Czkawka.

I think it would be better to focus on Czkawka for any future integration or features surrounding duplicate files.

[SUGGESTION] Ctrl + Shift click multiselect functionality

Suggestion:
Make it so that if you press ctrl+shift, you'll be able to select in between your previous item selected, and the next item selected WHILE still keeping the items you previously selected.
Currently, doing CTRL + SHIFT + click cancels all the previously selected and selects the item you clicked.
Adding this suggestion would help make selection even more convient

Ignore folders and file patterns

Sorta like a .gitignore, to be able to ignore folder patterns like (ironically) .git, cache folders, and .DS_Store and the likes.

Else all the hundreds or so hidden files will clog up the interface, with no way to hide them.

[Suggestion] List view

Switching between a list/column view and the current grid view could be useful for users that want to use this for text/binary file organization instead of images

[suggestion] [provided] Executable

Heya, it's me again;
I created a small .exe file that simply executes the start_win.bat file via the system call start start_win.bat.
This is the whole C-Code I used to create the .exe:

#include <stdlib.h>

int main(void) {
    system("start hide_bat.vbs");
    return 0;
}

I also then used Resource Hacker to change the icon to the tagstudio\resources\icon.ico provided by the source-code.
The code, exe and vbs file(s) are provided here.

[Suggestion] Add option to sort or group files by file type

Tag Studio is nice but kind of unusable when you have a lot of different file types (like zip files, pdfs, text files etc.) among your pictures in the same folders. It makes your library look like this everywhere, where it is really difficult to find what you are actually looking for:

grafik

So it would be nice if it were possible to sort or group the files by their file type. As an example of how this could work is the Windows file explorer, which has both of these. What I could imagine would be buttons at the top of Tag Studio similar to this:

example for sort by example for group by

which would sort the files in your library or group them under headings depending on their file type, like in the windows explorer

[SUGGESTION] Possible Solution for relinking renamed/moved files

First: I don't have a full grasp as how two tags are implemented, so this may not be an option.

Can this issue be solved by creating a checksum based off the file and/or providing the file a hidden tag / database ID number, which would then continue with the file? The idea would be this number is unique, and can then be found later on if the file was previously in Tag Studio but later removed.

For example, if I took file cat.png, after it was already in Tag Studio and it was given ID: 1, then I removed it from my computer and later added that same file that had the hidden ID: 1, TagStudio would then know what that file is (or it's other associated tags).

This implementation may require a local database or log file, which may be against your implementation idea, but it could help with recovery on that specific computer.

Graphical Tag Editor/Viewer

Having a graphical node-based editor/viewer for tags would greatly improve the experience dealing with complex tag setups.

[Suggestion] Tag Database window be able to remain open

Currently the Tag Database window needs to be closed in order to continue to use the app. It would be convenient to have it open while you for example add new tags.

Also a New Tag button in the Tag Database window would be nice.

[BUG] Adding a new tag with multiple file selected only adds it to a single file

System Details:
System: Garuda Linux (Arch)
Python version: 3.11.8
TagStudio Version: 6c5f0c2 (main)

Expected:
When multiple files are selected and a tag is added to a field that all files have, it should be added to all files.

Actually happens:
The tag is only added to the first file selected in the multi select.

Side note:
This also leads into the suggestion that when multiple files are selected they should show the intersection of all tags instead of only displaying "Mixed Data" this could allow for removing of tags from a bunch of files that don't share all of their tags.

Last:
Also just wanted to say thanks for open sourcing this, over the next few days I'm going to read over the code base and look into the audio waveform generation as I mentioned in my comment on youtube.

[SUGGESTION] User-Defined Field Names

BUG

I assume that you should not be able to add the same fields an infinite number of times.

image

SUGGESTION

Or you should be able to give a name to the field when adding the fields to distinguish them.
You could then give different additional things to the fields like that you can only add a certain group of tags to a certain tag field.
If you can understand what I mean.

[SUGGESTION] Use OCR/Text from files to create auto generated tags or search

OCR being eventually implemented into this program would be great, but that's not exactly what I'm asking. It would be helpful if the program could automatically generate tags OR allow content to be searchable for Word / PDF type documents. I understand the initial implementation of this is for more assets, but this would make it very useful for documents as well.

One piece of software I use daily that helps with some of this is DevonThink 3 for MacOS (but that is more of its own database file manager than something TagStudio aims to be). But if the goal of TagStudio is to help finding files easier, implementing the search inside files or search through OCR could be incredibly powerful.

[Suggestion] Tags that act like values

So I just came across this project and must admit it pretty much answers all my needs... All your problems with sorting files is pretty much what I've been frustrated with for the longest time. And I noticed there is potential for it to become far more than just a tagging and sorting system. It could become the one stop solution for all your sorting/tracking needs.

My suggestion might sound a little bit more than what you intend to do but it could pretty much separate this project apart from everything else in light years.

Basically, why not have tags as values? Much like properties in Obsidian... Lets say you have a lot of receipts you want to keep track of, add a price value and with an add-on like an expense tracker you can use these values to build a summary of your expenses. But wait, there's more. Maybe you're a crazy person and you want to keep track of how many cats you've photographed your entire life. Add a value to the photo and have it calculate how many cats you've photographed.
Or maybe you want something more practical? well... how about a due date? add a notification pusher add-on and have the app remind you about a certain file, maybe its a file you need to work on, or maybe its a PDF of a bill you need to pay? the possibles are endless. Values can be extensible and used for pretty much anything.

multiple selection in setting tags

When you select multiple image with shift and then attempts to add tags, in this case to meta tags, the first selected image is the only image in the selection of 3 (as I tested it) that were added the tag to meta tags. the 2 others had not the tag added.

Cannot Create Library on network path

https://github.com/CyanVoxel/TagStudio/blob/45e765862d7ec2bf2928134a91f0251a4d0b2068/tagstudio/src/core/library.py#L534-L543

Strips leading // from server shares e.g. //<server>/<share> gets normalized to \\\\<server>\\<share> but then gets caught by the above line stripping the leading \\ which in turn causes
https://github.com/CyanVoxel/TagStudio/blob/45e765862d7ec2bf2928134a91f0251a4d0b2068/tagstudio/src/core/library.py#L560-L571
to fail as the address is no longer accurate

swapping from strip to rstrip only removes trailing \\
path = os.path.normpath(path).strip('\\') becomes
path = os.path.normpath(path).rstrip('\\')
Which works for network shares.

[suggestion] Plugin support or simple export of database

Hello.
As promised, TagStudio is added to my TagsResearch repository. Took me a while because I stumbled upon few new programs in your comments section and found few new on Github and HN. You're free to check TagStudio entry and see if everything is correct (and maybe fill out empty spaces). But it's only based on the readme and screenshots so only the features that are currently in version 9.1.0-alpha were added.

Are there plans for plugin support? I would like to write a plugin that would take all files in the database and use as many details as possible to export a code that my QMV can use (for example: path, file name, type, size, tags). Alternatively just something that would export the file data in some text form (preferably JSON, but that's up to you) that can then be later processed by some other program.

Good job so far with TagStudio. Though I must say that we both have some fierce competition in this tagging field. But that's what makes it fun, right?

[SUGGESTION] Import Tag list

Having a ton of fun playing around with the program so far.
I think implementing an 'import tag' feature would be beneficial down the line, which would allow the community to share their entire tag set-ups, letting new users quickly get started.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.