1
1
import re
2
2
import shutil
3
+ import time
3
4
import unicodedata
4
5
from dataclasses import dataclass
5
6
from datetime import UTC , datetime
9
10
from uuid import uuid4
10
11
11
12
import structlog
13
+ from humanfriendly import format_timespan
12
14
from sqlalchemy import (
13
15
URL ,
14
16
Engine ,
17
+ NullPool ,
15
18
and_ ,
16
19
create_engine ,
17
20
delete ,
29
32
make_transient ,
30
33
selectinload ,
31
34
)
35
+ from src .core .library .json .library import Library as JsonLibrary # type: ignore
32
36
33
37
from ...constants import (
34
38
BACKUP_FOLDER_NAME ,
@@ -122,6 +126,7 @@ class LibraryStatus:
122
126
success : bool
123
127
library_path : Path | None = None
124
128
message : str | None = None
129
+ json_migration_req : bool = False
125
130
126
131
127
132
class Library :
@@ -133,7 +138,8 @@ class Library:
133
138
folder : Folder | None
134
139
included_files : set [Path ] = set ()
135
140
136
- FILENAME : str = "ts_library.sqlite"
141
+ SQL_FILENAME : str = "ts_library.sqlite"
142
+ JSON_FILENAME : str = "ts_library.json"
137
143
138
144
def close (self ):
139
145
if self .engine :
@@ -143,32 +149,119 @@ def close(self):
143
149
self .folder = None
144
150
self .included_files = set ()
145
151
152
+ def migrate_json_to_sqlite (self , json_lib : JsonLibrary ):
153
+ """Migrate JSON library data to the SQLite database."""
154
+ logger .info ("Starting Library Conversion..." )
155
+ start_time = time .time ()
156
+ folder : Folder = Folder (path = self .library_dir , uuid = str (uuid4 ()))
157
+
158
+ # Tags
159
+ for tag in json_lib .tags :
160
+ self .add_tag (
161
+ Tag (
162
+ id = tag .id ,
163
+ name = tag .name ,
164
+ shorthand = tag .shorthand ,
165
+ color = TagColor .get_color_from_str (tag .color ),
166
+ )
167
+ )
168
+
169
+ # Tag Aliases
170
+ for tag in json_lib .tags :
171
+ for alias in tag .aliases :
172
+ self .add_alias (name = alias , tag_id = tag .id )
173
+
174
+ # Tag Subtags
175
+ for tag in json_lib .tags :
176
+ for subtag_id in tag .subtag_ids :
177
+ self .add_subtag (parent_id = tag .id , child_id = subtag_id )
178
+
179
+ # Entries
180
+ self .add_entries (
181
+ [
182
+ Entry (
183
+ path = entry .path / entry .filename ,
184
+ folder = folder ,
185
+ fields = [],
186
+ id = entry .id + 1 , # JSON IDs start at 0 instead of 1
187
+ )
188
+ for entry in json_lib .entries
189
+ ]
190
+ )
191
+ for entry in json_lib .entries :
192
+ for field in entry .fields :
193
+ for k , v in field .items ():
194
+ self .add_entry_field_type (
195
+ entry_ids = (entry .id + 1 ), # JSON IDs start at 0 instead of 1
196
+ field_id = self .get_field_name_from_id (k ),
197
+ value = v ,
198
+ )
199
+
200
+ # Preferences
201
+ self .set_prefs (LibraryPrefs .EXTENSION_LIST , [x .strip ("." ) for x in json_lib .ext_list ])
202
+ self .set_prefs (LibraryPrefs .IS_EXCLUDE_LIST , json_lib .is_exclude_list )
203
+
204
+ end_time = time .time ()
205
+ logger .info (f"Library Converted! ({ format_timespan (end_time - start_time )} )" )
206
+
207
+ def get_field_name_from_id (self , field_id : int ) -> _FieldID :
208
+ for f in _FieldID :
209
+ if field_id == f .value .id :
210
+ return f
211
+ return None
212
+
146
213
def open_library (self , library_dir : Path , storage_path : str | None = None ) -> LibraryStatus :
214
+ is_new : bool = True
147
215
if storage_path == ":memory:" :
148
216
self .storage_path = storage_path
149
217
is_new = True
218
+ return self .open_sqlite_library (library_dir , is_new )
150
219
else :
151
- self .verify_ts_folders (library_dir )
152
- self .storage_path = library_dir / TS_FOLDER_NAME / self .FILENAME
153
- is_new = not self .storage_path .exists ()
220
+ self .storage_path = library_dir / TS_FOLDER_NAME / self .SQL_FILENAME
221
+
222
+ if self .verify_ts_folder (library_dir ) and (is_new := not self .storage_path .exists ()):
223
+ json_path = library_dir / TS_FOLDER_NAME / self .JSON_FILENAME
224
+ if json_path .exists ():
225
+ return LibraryStatus (
226
+ success = False ,
227
+ library_path = library_dir ,
228
+ message = "[JSON] Legacy v9.4 library requires conversion to v9.5+" ,
229
+ json_migration_req = True ,
230
+ )
231
+
232
+ return self .open_sqlite_library (library_dir , is_new )
154
233
234
+ def open_sqlite_library (
235
+ self , library_dir : Path , is_new : bool , add_default_data : bool = True
236
+ ) -> LibraryStatus :
155
237
connection_string = URL .create (
156
238
drivername = "sqlite" ,
157
239
database = str (self .storage_path ),
158
240
)
241
+ # NOTE: File-based databases should use NullPool to create new DB connection in order to
242
+ # keep connections on separate threads, which prevents the DB files from being locked
243
+ # even after a connection has been closed.
244
+ # SingletonThreadPool (the default for :memory:) should still be used for in-memory DBs.
245
+ # More info can be found on the SQLAlchemy docs:
246
+ # https://docs.sqlalchemy.org/en/20/changelog/migration_07.html
247
+ # Under -> sqlite-the-sqlite-dialect-now-uses-nullpool-for-file-based-databases
248
+ poolclass = None if self .storage_path == ":memory:" else NullPool
159
249
160
- logger .info ("opening library" , library_dir = library_dir , connection_string = connection_string )
161
- self .engine = create_engine (connection_string )
250
+ logger .info (
251
+ "Opening SQLite Library" , library_dir = library_dir , connection_string = connection_string
252
+ )
253
+ self .engine = create_engine (connection_string , poolclass = poolclass )
162
254
with Session (self .engine ) as session :
163
255
make_tables (self .engine )
164
256
165
- tags = get_default_tags ()
166
- try :
167
- session .add_all (tags )
168
- session .commit ()
169
- except IntegrityError :
170
- # default tags may exist already
171
- session .rollback ()
257
+ if add_default_data :
258
+ tags = get_default_tags ()
259
+ try :
260
+ session .add_all (tags )
261
+ session .commit ()
262
+ except IntegrityError :
263
+ # default tags may exist already
264
+ session .rollback ()
172
265
173
266
# dont check db version when creating new library
174
267
if not is_new :
@@ -219,7 +312,6 @@ def open_library(self, library_dir: Path, storage_path: str | None = None) -> Li
219
312
db_version = db_version .value ,
220
313
expected = LibraryPrefs .DB_VERSION .default ,
221
314
)
222
- # TODO - handle migration
223
315
return LibraryStatus (
224
316
success = False ,
225
317
message = (
@@ -354,8 +446,12 @@ def tags(self) -> list[Tag]:
354
446
355
447
return list (tags_list )
356
448
357
- def verify_ts_folders (self , library_dir : Path ) -> None :
358
- """Verify/create folders required by TagStudio."""
449
+ def verify_ts_folder (self , library_dir : Path ) -> bool :
450
+ """Verify/create folders required by TagStudio.
451
+
452
+ Returns:
453
+ bool: True if path exists, False if it needed to be created.
454
+ """
359
455
if library_dir is None :
360
456
raise ValueError ("No path set." )
361
457
@@ -366,6 +462,8 @@ def verify_ts_folders(self, library_dir: Path) -> None:
366
462
if not full_ts_path .exists ():
367
463
logger .info ("creating library directory" , dir = full_ts_path )
368
464
full_ts_path .mkdir (parents = True , exist_ok = True )
465
+ return False
466
+ return True
369
467
370
468
def add_entries (self , items : list [Entry ]) -> list [int ]:
371
469
"""Add multiple Entry records to the Library."""
@@ -507,21 +605,23 @@ def search_library(
507
605
508
606
def search_tags (
509
607
self ,
510
- search : FilterState ,
608
+ name : str ,
511
609
) -> list [Tag ]:
512
610
"""Return a list of Tag records matching the query."""
611
+ tag_limit = 100
612
+
513
613
with Session (self .engine ) as session :
514
614
query = select (Tag )
515
615
query = query .options (
516
616
selectinload (Tag .subtags ),
517
617
selectinload (Tag .aliases ),
518
- )
618
+ ). limit ( tag_limit )
519
619
520
- if search . tag :
620
+ if name :
521
621
query = query .where (
522
622
or_ (
523
- Tag .name .icontains (search . tag ),
524
- Tag .shorthand .icontains (search . tag ),
623
+ Tag .name .icontains (name ),
624
+ Tag .shorthand .icontains (name ),
525
625
)
526
626
)
527
627
@@ -531,7 +631,7 @@ def search_tags(
531
631
532
632
logger .info (
533
633
"searching tags" ,
534
- search = search ,
634
+ search = name ,
535
635
statement = str (query ),
536
636
results = len (res ),
537
637
)
@@ -694,7 +794,7 @@ def add_entry_field_type(
694
794
* ,
695
795
field : ValueType | None = None ,
696
796
field_id : _FieldID | str | None = None ,
697
- value : str | datetime | list [str ] | None = None ,
797
+ value : str | datetime | list [int ] | None = None ,
698
798
) -> bool :
699
799
logger .info (
700
800
"add_field_to_entry" ,
@@ -727,8 +827,11 @@ def add_entry_field_type(
727
827
728
828
if value :
729
829
assert isinstance (value , list )
730
- for tag in value :
731
- field_model .tags .add (Tag (name = tag ))
830
+ with Session (self .engine ) as session :
831
+ for tag_id in list (set (value )):
832
+ tag = session .scalar (select (Tag ).where (Tag .id == tag_id ))
833
+ field_model .tags .add (tag )
834
+ session .flush ()
732
835
733
836
elif field .type == FieldTypeEnum .DATETIME :
734
837
field_model = DatetimeField (
@@ -760,6 +863,28 @@ def add_entry_field_type(
760
863
)
761
864
return True
762
865
866
+ def tag_from_strings (self , strings : list [str ] | str ) -> list [int ]:
867
+ """Create a Tag from a given string."""
868
+ # TODO: Port over tag searching with aliases fallbacks
869
+ # and context clue ranking for string searches.
870
+ tags : list [int ] = []
871
+
872
+ if isinstance (strings , str ):
873
+ strings = [strings ]
874
+
875
+ with Session (self .engine ) as session :
876
+ for string in strings :
877
+ tag = session .scalar (select (Tag ).where (Tag .name == string ))
878
+ if tag :
879
+ tags .append (tag .id )
880
+ else :
881
+ new = session .add (Tag (name = string ))
882
+ if new :
883
+ tags .append (new .id )
884
+ session .flush ()
885
+ session .commit ()
886
+ return tags
887
+
763
888
def add_tag (
764
889
self ,
765
890
tag : Tag ,
@@ -852,7 +977,7 @@ def save_library_backup_to_disk(self) -> Path:
852
977
target_path = self .library_dir / TS_FOLDER_NAME / BACKUP_FOLDER_NAME / filename
853
978
854
979
shutil .copy2 (
855
- self .library_dir / TS_FOLDER_NAME / self .FILENAME ,
980
+ self .library_dir / TS_FOLDER_NAME / self .SQL_FILENAME ,
856
981
target_path ,
857
982
)
858
983
@@ -879,15 +1004,15 @@ def get_alias(self, tag_id: int, alias_id: int) -> TagAlias:
879
1004
880
1005
return alias
881
1006
882
- def add_subtag (self , base_id : int , new_tag_id : int ) -> bool :
883
- if base_id == new_tag_id :
1007
+ def add_subtag (self , parent_id : int , child_id : int ) -> bool :
1008
+ if parent_id == child_id :
884
1009
return False
885
1010
886
1011
# open session and save as parent tag
887
1012
with Session (self .engine ) as session :
888
1013
subtag = TagSubtag (
889
- parent_id = base_id ,
890
- child_id = new_tag_id ,
1014
+ parent_id = parent_id ,
1015
+ child_id = child_id ,
891
1016
)
892
1017
893
1018
try :
@@ -899,6 +1024,22 @@ def add_subtag(self, base_id: int, new_tag_id: int) -> bool:
899
1024
logger .exception ("IntegrityError" )
900
1025
return False
901
1026
1027
+ def add_alias (self , name : str , tag_id : int ) -> bool :
1028
+ with Session (self .engine ) as session :
1029
+ alias = TagAlias (
1030
+ name = name ,
1031
+ tag_id = tag_id ,
1032
+ )
1033
+
1034
+ try :
1035
+ session .add (alias )
1036
+ session .commit ()
1037
+ return True
1038
+ except IntegrityError :
1039
+ session .rollback ()
1040
+ logger .exception ("IntegrityError" )
1041
+ return False
1042
+
902
1043
def remove_subtag (self , base_id : int , remove_tag_id : int ) -> bool :
903
1044
with Session (self .engine ) as session :
904
1045
p_id = base_id
0 commit comments