315 Commits

Author SHA1 Message Date
H1K0 676b254187 chore: prepare for release 2.0.0 2023-02-22 08:45:48 +03:00
H1K0 c1a4d79481 fix(web): fix h3 font size 2023-02-22 08:38:20 +03:00
H1K0 cb2de0658f chore(tfm): remove -V option 2023-02-22 08:32:10 +03:00
H1K0 9795b4dc2d perf(dbms): minimize databases stats response 2023-02-21 00:35:21 +03:00
H1K0 e5abcaae0d chore(docs): update docs 2023-02-21 00:17:43 +03:00
H1K0 a2179c8ed3 fix(tfm): check file path when adding new 2023-02-20 23:53:37 +03:00
H1K0 6e105f6aa9 refactor(tfm): change config file location 2023-02-20 23:46:59 +03:00
H1K0 1c62fd8219 fix(tfm): fix CLI app 2023-02-18 01:01:02 +03:00
H1K0 90ac5324ea fix(web): fix tag view menu lazy load 2023-02-18 00:50:35 +03:00
H1K0 1243c4dc93 chore: add TDBMS CLI client to CMakeLists.txt 2023-02-17 23:57:22 +03:00
H1K0 d2867e2d99 feat(dbms): add help message to CLI client 2023-02-17 23:56:56 +03:00
H1K0 c9b921becf fix(web): fix tdb response checking in js 2023-02-17 23:51:45 +03:00
H1K0 880d4df453 style(web): add background to apple and android favicon 2023-02-17 20:16:11 +03:00
H1K0 3f5527db56 perf(web): optimize adding/removing tags to files 2023-02-17 13:29:57 +03:00
H1K0 832eb72bf4 feat(dbms): add new request codes
`trc_kazari_remove_single_sasa_to_multiple_tanzaku` and `trc_kazari_remove_single_tanzaku_to_multiple_sasa`
2023-02-17 11:34:27 +03:00
H1K0 53f09a5d82 feat(web): add new database adding interface 2023-02-15 22:15:18 +03:00
H1K0 b0e9d2631b fix(web): add missing viewport metas 2023-02-15 21:51:43 +03:00
H1K0 2c6b41c408 perf(web): improve button flex design 2023-02-15 21:46:09 +03:00
H1K0 fc4887ef2e fix(web): fix TFM database buttons behavior 2023-02-15 21:35:06 +03:00
H1K0 873ecf02ff feat(web): add database save, reload and remove buttons handling to TDBMS interface 2023-02-15 21:34:43 +03:00
H1K0 5b36a31b84 fix(web): fix design 2023-02-15 20:38:09 +03:00
H1K0 215f8d135a fix(web): fix settings handling 2023-02-15 20:11:30 +03:00
H1K0 f515dc3944 perf(web): return to home page when pressed escape on auth page 2023-02-15 19:32:05 +03:00
H1K0 8eb11f6035 fix(web): update server to handle database management interface 2023-02-15 15:31:32 +03:00
H1K0 1f3d015870 perf(web): change TFM header 2023-02-15 15:19:34 +03:00
H1K0 ba5418dc39 feat(web): add database management interface 2023-02-15 15:19:08 +03:00
H1K0 fb56cd6076 perf(web): change buttons design 2023-02-15 13:05:25 +03:00
H1K0 1a1f120777 style(web): a bit of code cleanup 2023-02-13 22:10:05 +03:00
H1K0 c9dc59ecec fix(web): fix authorization handling 2023-02-13 18:11:20 +03:00
H1K0 cc55219e46 feat(web): add handling tag description 2023-02-12 17:12:22 +03:00
H1K0 d133400103 perf(web): improve design 2023-02-12 16:57:38 +03:00
H1K0 fc90276399 fix(web): wrap button row 2023-02-12 16:42:48 +03:00
H1K0 bf88d346e4 feat(web): add renaming tag 2023-02-12 16:39:31 +03:00
H1K0 0ad59c40bd refactor(web): change database reload button id 2023-02-12 16:19:27 +03:00
H1K0 5f8cb21f03 fix(web): reset hyous mts on reloading database 2023-02-12 16:17:38 +03:00
H1K0 39215e02ce fix(web): save sorted hyou to localStorage 2023-02-12 16:08:43 +03:00
H1K0 14226db236 fix(web): reset hyous mts on changing database 2023-02-12 15:52:13 +03:00
H1K0 6f0359e99f style(web): simplify tdb_query() func 2023-02-12 15:51:16 +03:00
H1K0 e8e32c70f0 feat(web): add sorting sasa and tanzaku 2023-02-12 15:35:52 +03:00
H1K0 e72d371ff1 fix(web): fix settings page radio ids 2023-02-09 19:22:48 +03:00
H1K0 cd6cae7316 perf(web): improve tdbms.js 2023-02-09 19:18:38 +03:00
H1K0 87404e34d3 refactor(web): move common TDBMS functions to tdbms.js 2023-02-09 18:49:25 +03:00
H1K0 a1e6f3e9e1 perf(web): throw js error on unauthorized TDBMS request 2023-02-09 18:37:45 +03:00
H1K0 f7c8923199 perf(web): some improvements in buttons 2023-02-09 01:08:48 +03:00
H1K0 243621f3b2 feat(web): add TFM settings page
Only setting TFM database name by now
2023-02-09 00:45:54 +03:00
H1K0 9c07702409 perf(web): switch to dark theme 2023-02-07 02:18:37 +03:00
H1K0 84e3dd19e1 feat(dbms): introduce TDBMS CLI client app 2023-02-05 15:06:16 +03:00
H1K0 86dae4c264 perf(web): do not close menu on submit 2023-02-04 16:30:23 +03:00
H1K0 8423314b73 style(web): change theme color 2023-02-04 00:24:04 +03:00
H1K0 40cd9ca49f fix(web): fix and improve substring filtering 2023-02-03 23:39:39 +03:00
H1K0 6eb34f3b59 fix(web): fix lazy load on tag view menu 2023-02-03 23:33:59 +03:00
H1K0 223e174568 feat(web): new menu handling supports two view menus at once 2023-02-03 22:51:35 +03:00
H1K0 371b091c10 perf(web): improve text input positioning in file menu 2023-02-03 20:26:44 +03:00
H1K0 48475158f8 perf(web): reduce preview max height 2023-02-03 16:43:18 +03:00
H1K0 94105491af feat(web): redirect client when TDBMS response status is 401 2023-02-03 16:39:39 +03:00
H1K0 dc937444e5 perf(web): do not redirect unauthorized TDBMS requests 2023-02-03 16:34:28 +03:00
H1K0 dfe53d3e16 perf(web): remove highlighting file menu navigation buttons 2023-02-03 01:30:06 +03:00
H1K0 4d2326986f feat(web): try to reconnect to TDBMS server on failure to get response 2023-02-03 01:08:24 +03:00
H1K0 e7be627292 feat(web): make button-flex position fixed at the bottom of menu 2023-02-03 00:22:09 +03:00
H1K0 c960eb6730 feat(web): use arrow keys for file navigation 2023-02-02 23:56:35 +03:00
H1K0 560b255b69 feat(web): add prev/next navigation buttons to file view menu 2023-02-02 23:50:11 +03:00
H1K0 82cd625988 fix(web): fix view menu open and close functions 2023-02-02 23:34:57 +03:00
H1K0 b7ad148d27 refactor(web): move menu open/close actions into separate functions 2023-02-02 23:28:18 +03:00
H1K0 689e6f9136 perf(web): a tiny improvement in tdbms.js 2023-02-02 23:00:08 +03:00
H1K0 21f57e8767 fix(web): fix localStorage management 2023-02-02 22:59:32 +03:00
H1K0 7f5daee11e perf(web): improve menu sizing 2023-02-02 21:00:25 +03:00
H1K0 6cd7bb73fe perf(web): save hyous to localStorage 2023-02-02 18:18:11 +03:00
H1K0 ee787dab18 feat(dbms): return new tanzaku info on successful adding tanzaku 2023-02-02 16:20:52 +03:00
H1K0 24c9e39084 feat(core,lib): return new tanzaku when adding tanzaku or HOLE_TANZAKU on failure 2023-02-02 16:15:30 +03:00
H1K0 d8de645780 feat(dbms): return new sasa info on successful adding sasa 2023-02-02 16:12:28 +03:00
H1K0 2ba60d49e6 perf(core,lib): return new sasa when adding sasa or HOLE_SASA on failure 2023-02-02 16:09:21 +03:00
H1K0 8b86a34a71 feat(core,lib): return new sasa ID when adding sasa or HOLE_ID on failure 2023-02-02 15:53:02 +03:00
H1K0 53320831bb feat(web): lazy load file thumbs on tags menu 2023-02-02 13:44:44 +03:00
H1K0 8089c6ae68 feat(web): show image preview in file view menu 2023-02-02 00:05:04 +03:00
H1K0 ef373db8d5 perf(web): remove js cookie management 2023-02-01 20:28:53 +03:00
H1K0 e81ee683b7 style(web): a bit of code cleanup 2023-02-01 20:16:24 +03:00
H1K0 083eac3fee perf(web): use thumbnails instead of full sized images 2023-01-31 23:07:35 +03:00
H1K0 eaa7c2f2ce chore(web): a bit of html cleanup 2023-01-31 18:46:25 +03:00
H1K0 9226b6cf5a feat(web): introduce lazy loading file thumbs on files page 2023-01-31 18:45:57 +03:00
H1K0 9a2fa14a79 fix(lib): load database anyway 2023-01-31 16:46:24 +03:00
H1K0 4b7766695e feat(web): introduce tag removing 2023-01-31 16:24:03 +03:00
H1K0 b4b76fe271 chore(dbms): fix installer 2023-01-31 16:06:07 +03:00
H1K0 3a261d5d01 chore(web): add tweb server installation script and systemd service config 2023-01-31 16:05:39 +03:00
H1K0 3a5804d235 perf(web): temporary set thumbs directory equal to files dir 2023-01-31 15:59:13 +03:00
H1K0 a3d901a599 perf(web): reduce sasa margin 2023-01-31 15:52:31 +03:00
H1K0 440468bab5 feat(web): log time with microseconds 2023-01-31 15:41:06 +03:00
H1K0 c62d5fa512 fix(web): fix authorization 2023-01-31 15:28:28 +03:00
H1K0 0c03d3a791 perf(web): improve logging 2023-01-31 15:23:46 +03:00
H1K0 515beac231 fix(web): fix authorization 2023-01-31 15:20:35 +03:00
H1K0 27800737e3 feat(web): log to file 2023-01-31 15:00:20 +03:00
H1K0 50d9555d2d perf(web): change server configuration 2023-01-31 14:39:44 +03:00
H1K0 a3e2062de7 fix(dbms): fix socket file permissions 2023-01-31 14:28:39 +03:00
H1K0 fefff3ce45 feat(web): introduce saving and discarding changes 2023-01-31 02:08:42 +03:00
H1K0 70ac14a17a refactor(web): rename tfm.js to tfm-management.js 2023-01-31 01:57:28 +03:00
H1K0 32000c2b9f refactor(web): remove unused variables 2023-01-31 01:55:05 +03:00
H1K0 70f593997d perf(web): improve form submit handling 2023-01-31 01:54:33 +03:00
H1K0 9cf6daefb8 chore(web): change auth submit element from input to button 2023-01-31 01:49:07 +03:00
H1K0 0c84504d2c feat(web): introduce adding new tag to database 2023-01-31 01:43:59 +03:00
H1K0 789e24b675 fix(web): fix bug in TDB query handling 2023-01-31 01:40:36 +03:00
H1K0 52b54ba792 feat(web): introduce adding new file (path) to database 2023-01-31 01:21:30 +03:00
H1K0 b535faa4c7 perf(web): some improvements in design 2023-01-31 01:03:59 +03:00
H1K0 ca0bd0726a feat(web): add text filter to tags page 2023-01-30 23:31:06 +03:00
H1K0 478e67cc95 perf(web): improve contents list design 2023-01-30 23:28:17 +03:00
H1K0 e9f848d39f perf(web): make list elements justified 2023-01-30 22:03:48 +03:00
H1K0 9eccd06395 perf(web): make headers' font size adaptive 2023-01-30 22:02:38 +03:00
H1K0 59e6b3ba8b fix(web): fix scrolling on mobile 2023-01-30 21:54:13 +03:00
H1K0 9f94387fb0 fix(web): fix list height 2023-01-30 21:53:07 +03:00
H1K0 5a78facf76 fix(dbms): escape TDB names in response 2023-01-30 20:23:39 +03:00
H1K0 e9c0c6340a perf(dbms): improve TDBMS installer script 2023-01-30 18:30:08 +03:00
H1K0 d72348e639 refactor(web): change filters ids 2023-01-29 23:11:46 +03:00
H1K0 109cc1ac32 fix(web): text filtering works only with shown elements 2023-01-29 21:31:42 +03:00
H1K0 803f723015 fix(web): fix selection filtering 2023-01-29 21:21:34 +03:00
H1K0 08b18063aa fix(web): a little bug fix in server code 2023-01-29 21:12:58 +03:00
H1K0 591d598d24 perf(dbms): remove extra code line 2023-01-29 20:58:24 +03:00
H1K0 733780c17c feat(web): add filtering only selected items in list 2023-01-29 20:20:10 +03:00
H1K0 351e0be3ca feat(web): add TFM tags page 2023-01-29 19:18:13 +03:00
H1K0 58bd284101 fix(web): change file menu title 2023-01-29 19:16:37 +03:00
H1K0 efe4339b33 fix(web): fix list height 2023-01-29 19:15:36 +03:00
H1K0 a089d519ea chore(web): some changes in css 2023-01-29 18:52:57 +03:00
H1K0 0871d9209d fix(web): prevent default on double click 2023-01-29 18:50:27 +03:00
H1K0 552cb2653f fix(web): some little fixes in tfm-files.js 2023-01-29 18:49:58 +03:00
H1K0 8b852401a5 perf(web): don't load shoppyou on load 2023-01-29 18:26:16 +03:00
H1K0 20dc58f951 refactor(web): rename hyou load functions 2023-01-29 18:25:58 +03:00
H1K0 ccca7ac6b9 fix(web): fix TFM pages titles 2023-01-29 18:22:17 +03:00
H1K0 b618ecbd52 refactor(web): separate TFM files page 2023-01-29 18:20:39 +03:00
H1K0 9bfb9f89fe refactor(web): create /tfm directory for TFM pages 2023-01-29 16:57:17 +03:00
H1K0 8c2fba25f0 feat(web): adding/removing file tags 2023-01-29 16:43:11 +03:00
H1K0 6f3ac78fd0 fix(dbms): clear response when request requires no data 2023-01-29 16:17:07 +03:00
H1K0 bf4e127908 perf(web): redirect to /auth on fail to get TDBMS response 2023-01-29 16:00:02 +03:00
H1K0 c78e380d23 feat(web): add tanzaku filtering to sasa menu 2023-01-29 15:30:53 +03:00
H1K0 ed855ba9f9 perf(web): improve sasa menu design 2023-01-29 15:20:50 +03:00
H1K0 9159cd0aca refactor(web): move form and .button-flex styles to general.css 2023-01-29 13:55:17 +03:00
H1K0 5cb498112d feat(web): initialize sasa info menu 2023-01-29 02:45:39 +03:00
H1K0 660ff87af6 feat(web): add authorization page link to home page 2023-01-29 00:46:03 +03:00
H1K0 5725015408 feat(web): initialize TFM page
Only table of thumbs by now
2023-01-29 00:45:20 +03:00
H1K0 b55865b8e7 fix(web): escape file paths 2023-01-29 00:43:44 +03:00
H1K0 f5eb8dac06 feat(web): mask real file paths when working with TFM database 2023-01-28 21:51:56 +03:00
H1K0 94b55c8a87 perf(dbms): improve response structure 2023-01-28 21:41:51 +03:00
H1K0 8b9861de18 fix(dbms): escape slash in json 2023-01-28 19:56:14 +03:00
H1K0 607cd6df09 perf(web): improve authentication 2023-01-28 19:18:11 +03:00
H1K0 0a76f7fd8e feat(web): show 'Back to home' button on auth when valid token 2023-01-27 17:19:14 +03:00
H1K0 deb00436f5 perf(web): add validate function to js 2023-01-27 17:17:47 +03:00
H1K0 7c58f011ca fix(web): fix token.js 2023-01-27 17:00:35 +03:00
H1K0 46e67488f1 fix(web): initialize index.html and update styles 2023-01-27 16:53:24 +03:00
H1K0 b791c60b81 fix(web): update styles 2023-01-27 16:52:15 +03:00
H1K0 7e0e619950 chore(web): change from CRLF to LF in css 2023-01-27 15:29:58 +03:00
H1K0 aafaf2d32c fix(web): fix assets paths in html 2023-01-27 15:24:51 +03:00
H1K0 ebb3836930 feat(web): initialize frontend 2023-01-27 01:33:23 +03:00
H1K0 47ee5c4776 feat(web): add token validation handler 2023-01-27 01:21:18 +03:00
H1K0 9a4817d6ad refactor(web): move all server files into server directory 2023-01-27 01:11:24 +03:00
H1K0 2981a4b0ca chore(web): add go.mod 2023-01-27 01:06:58 +03:00
H1K0 01f2b24bfc chore: remove unused constants.h 2023-01-27 01:05:50 +03:00
H1K0 9d22038261 chore(dbms): rename 'unsaved' stats key to 'changed' 2023-01-27 00:43:57 +03:00
H1K0 540a039057 chore(dbms): separate date and time with space in logs 2023-01-27 00:41:45 +03:00
H1K0 94b3776ddf fix(dbms): escape JSON strings 2023-01-27 00:38:46 +03:00
H1K0 6dd7421050 fix(dbms): remove trailing comma from response 2023-01-26 23:38:48 +03:00
H1K0 9828e0f213 feat(web): add TDBMS client 2023-01-26 23:29:52 +03:00
H1K0 77d1044742 fix(dbms): remove hex numbers notation from json response 2023-01-26 23:05:06 +03:00
H1K0 b80eaf55ea chore(web): change assets paths 2023-01-26 21:51:11 +03:00
H1K0 a5728f7838 chore(dbms): add TRC bits enum to tdbms.h 2023-01-26 21:49:48 +03:00
H1K0 58868ff681 fix(dbms): put EOT char in the end of request/response 2023-01-26 17:29:01 +03:00
H1K0 23aae2c3b1 refactor(dbms): tdb_query returns response 2023-01-23 21:04:49 +03:00
H1K0 bf1a652b91 feat(web): improve token generation 2023-01-23 16:16:17 +03:00
H1K0 f8d988883f chore(dbms): add tdbms server installation script and systemd service config 2023-01-22 17:22:21 +03:00
H1K0 ca9bf96eec fix(tfm): fix CLI 2023-01-21 18:58:35 +03:00
H1K0 aa0f85397f fix(dbms): fix tdb list loading 2023-01-21 18:45:12 +03:00
H1K0 b1c6229968 fix(dbms): fix tdb load and save operations handling 2023-01-21 18:44:54 +03:00
H1K0 085b337a5e feat(dbms): save db list after adding/removing/editing any 2023-01-21 18:37:46 +03:00
H1K0 a389d959e0 perf(dbms): optimize request execution 2023-01-21 18:34:03 +03:00
H1K0 3783d8061b fix(dbms): send empty list instead of false status 2023-01-21 17:50:47 +03:00
H1K0 45ce484636 fix(lib): some bug fixes in kazari section 2023-01-21 17:48:18 +03:00
H1K0 9de309e142 feat(dbms): add kazari operatons handling 2023-01-21 17:44:15 +03:00
H1K0 a127cae637 fix(dbms): remove unused TRC 2023-01-21 17:32:44 +03:00
H1K0 f103e87020 fix(dbms): some little fixes 2023-01-21 16:38:13 +03:00
H1K0 66c1a17ad5 feat(dbms): add remove sasa by tanzaku and tanzaku by sasa operations 2023-01-21 16:19:37 +03:00
H1K0 ea720c449c feat(dbms): add tanzaku operations handling 2023-01-21 15:57:16 +03:00
H1K0 53277ee2ac fix(dbms): fix a little bug in client lib 2023-01-21 14:33:38 +03:00
H1K0 fae600d5d4 feat(dbms): send all tdb stats when got empty db name with trc_db_stats 2023-01-21 14:21:02 +03:00
H1K0 225b1be031 feat(dbms): add sasa operations handling 2023-01-21 02:16:37 +03:00
H1K0 7fa946dd02 fix(core): return 0 when removing non-existent kazari 2023-01-21 02:12:32 +03:00
H1K0 93c0087cbc fix(lib): a little fix in kazari section 2023-01-21 01:54:18 +03:00
H1K0 e130405459 fix(dbms): fix stats JSON and db remove functions 2023-01-21 00:48:01 +03:00
H1K0 871b76d43a init(dbms): introduce Tanabata database management system (TDBMS)
Only basic database operations, only Unix domain sockets by now
2023-01-20 22:50:01 +03:00
H1K0 08da5aeb79 fix(lib): a little bug fix in weed function 2023-01-20 22:03:36 +03:00
H1K0 cc795bae19 fix(lib): some bug fixes, optimization improves and code cleanup 2023-01-20 17:18:23 +03:00
H1K0 8768d55b48 feat(lib): remove sasa get/remove by path and tanzaku get/remove by name functions 2023-01-20 15:58:27 +03:00
H1K0 0db444b65c style(core): for loops stylization 2023-01-20 15:41:47 +03:00
H1K0 d90570962b perf(lib): remove file path validation 2023-01-20 13:14:45 +03:00
H1K0 20ae688a31 fix(core): a lot of bug fixes, optimization improves and some code cleanup 2023-01-20 02:26:21 +03:00
H1K0 7d97948e74 fix(core): a little bug fix in sappyou.c 2023-01-19 01:51:53 +03:00
H1K0 7bdcd3e495 fix(lib): a little bug fix in tanabata_init function 2023-01-19 00:51:04 +03:00
H1K0 bf49221141 fix(core): a little bug fix on loading/saving hyou 2023-01-18 23:50:24 +03:00
H1K0 3fe58e8cc2 chore: a bit of stylization to CMakeLists.txt 2023-01-14 17:29:22 +03:00
H1K0 b829e22710 chore: set cmake project name to Tanabata 2023-01-14 17:25:54 +03:00
H1K0 221e547db5 refactor(core): make core_func.h local 2023-01-14 17:13:52 +03:00
H1K0 d421eadde4 refactor: rearrange files 2023-01-14 15:12:12 +03:00
H1K0 a69d8ac042 fix(core): add tanabata struct to core.h 2023-01-13 15:40:20 +03:00
H1K0 44ef8c32c2 perf: add constants.h file with global constants 2023-01-13 02:21:19 +03:00
H1K0 36acd4208a chore(lib): remove extra includes 2023-01-13 02:19:25 +03:00
H1K0 62773539cd refactor(core,lib): move all core and lib structs and constants to a separate header file 2023-01-13 02:17:09 +03:00
H1K0 50b1300847 chore(core,lib): add #pragma once to header files 2023-01-13 00:28:14 +03:00
H1K0 7f31d62260 init(web): add http server on golang with authentication only
And remove CGI
2023-01-10 20:08:17 +03:00
H1K0 e4188c69a4 feat(cli): print related sasa/tanzaku ID when viewing particular tanzaku/sasa 2023-01-08 23:34:39 +03:00
H1K0 e9ff845c5b chore: a little fix in build.sh 2023-01-04 03:09:32 +03:00
H1K0 5019c54380 init(cgi): add Common Gateway Interface with authentication only 2023-01-04 01:55:15 +03:00
H1K0 091b1757c6 chore: prepare for 1.0.0 release 2022-12-31 17:22:08 +03:00
H1K0 2ee6e5e8cc docs: some corrections 2022-12-31 15:06:57 +03:00
H1K0 1f37f7a2b8 docs: some minor corrections 2022-12-30 19:26:01 +03:00
H1K0 90af6dada8 docs: create a section for the CLI manual 2022-12-30 19:13:15 +03:00
H1K0 6119ed684e chore(cli): remove datetime from list view 2022-12-30 18:43:45 +03:00
H1K0 32fea5cd2b fix(lib): check if hyou is empty on remove 2022-12-30 18:20:52 +03:00
H1K0 5daa0db847 perf(lib): weed sappyou before shoppyou 2022-12-30 18:12:07 +03:00
H1K0 94aa958471 perf(lib): weeding also removes kazari with invalid sasa or tanzaku ID 2022-12-30 18:09:50 +03:00
H1K0 c7e38851f2 chore(cli): move '\n' outside of stylization macros 2022-12-30 17:34:15 +03:00
H1K0 66f1c42fcc chore(cli): some minor interface improvements 2022-12-30 16:48:33 +03:00
H1K0 3939b30c15 fix(cli): correct output messages in the update menu 2022-12-30 16:46:27 +03:00
H1K0 092188ac5e docs: update and correct usage manual 2022-12-30 16:20:36 +03:00
H1K0 d19f2b09b2 feat(cli): add option to edit sasa or tanzaku 2022-12-30 15:20:59 +03:00
H1K0 a03355f8ce feat(cli): print tanzaku last modification datetime 2022-12-30 15:12:41 +03:00
H1K0 ff0cd4eea2 fix(core): update tanzaku last modification timestamp on tanzaku update 2022-12-30 15:11:25 +03:00
H1K0 df12b94b2a fix(cli): colorize uncolorized error message 2022-12-30 15:05:04 +03:00
H1K0 2fb8038a44 feat(lib): add sasa and tanzaku update functions 2022-12-30 14:57:03 +03:00
H1K0 0b0bc93a8d feat(core): add sasa and tanzaku update functions 2022-12-30 13:47:00 +03:00
H1K0 90e5761d3e perf(core): remove some extra instructions 2022-12-29 21:34:10 +03:00
H1K0 28ee348dad docs: update usage manual 2022-12-29 19:06:10 +03:00
H1K0 c4f2149291 feat(cli)!: significant interface improvements 2022-12-29 18:35:01 +03:00
H1K0 2d46b72929 style(core): code cleanup 2022-12-29 18:04:34 +03:00
H1K0 60f44fb82f fix(lib): fix sasa add and remove functions 2022-12-29 17:32:00 +03:00
H1K0 12424fb995 fix(lib): check if got NULL in all functions 2022-12-29 17:24:37 +03:00
H1K0 dd1d513f1e fix(core): check if got NULL in all functions 2022-12-29 17:22:50 +03:00
H1K0 6a927f4644 fix(lib): fix bugs in weeding 2022-12-29 15:50:59 +03:00
H1K0 e64e6f7f47 feat(lib): weeding also removes sasa with invalid file path 2022-12-29 15:32:25 +03:00
H1K0 32e73acd73 feat(lib): zero tanzaku ID is reserved for the special immutable FAVORITE tag 2022-12-29 15:22:13 +03:00
H1K0 3cea0b5fdb style(lib): code cleanup 2022-12-29 14:55:03 +03:00
H1K0 eb746994e3 chore: update version number in CMakeLists.txt 2022-12-29 14:42:25 +03:00
H1K0 dc5abcf9d9 feat(cli): add option -i to view database info 2022-12-28 22:01:48 +03:00
H1K0 7961554b69 docs: prepare for 0.1.3-dev release 2022-12-28 18:58:05 +03:00
H1K0 29df398a50 fix(cli): check if successfully saved database after weeding 2022-12-28 18:56:44 +03:00
H1K0 631716877b feat(cli): colorize success message 2022-12-28 18:47:25 +03:00
H1K0 07ee9d3c7a fix(cli): fix bug on initializing new database 2022-12-28 18:39:01 +03:00
H1K0 caa01e2fbc docs: add hyou definition 2022-12-28 18:23:28 +03:00
H1K0 57623cff61 perf(lib): tanabata struct holds the hyou last modification timestamps instead of bools 2022-12-28 18:22:16 +03:00
H1K0 873d6d487b perf(core): a little improvement in new kazari remove functions 2022-12-28 18:02:33 +03:00
H1K0 97c63aea1f perf(lib): use new kazari remove functions 2022-12-28 17:58:01 +03:00
H1K0 4724962cd3 feat(core): add functions to remove all kazari with a specific sasa/tanzaku ID 2022-12-28 17:05:09 +03:00
H1K0 d8c43c7855 perf(lib): do not save unchanged database files 2022-12-28 16:48:35 +03:00
H1K0 2d41da9a8c feat(cli): change style of table output 2022-12-28 15:34:21 +03:00
H1K0 45160154df chore: change minimum required cmake version to 3.16 2022-12-28 14:59:10 +03:00
H1K0 eeedbfa484 feat(cli): print human-readable datetime instead of Unix timestamp 2022-12-28 01:47:15 +03:00
H1K0 133ef0b3a5 fix(core): check every operation on files 2022-12-28 01:03:19 +03:00
H1K0 5eb4775867 style(cli): code cleanup 2022-12-28 00:14:55 +03:00
H1K0 575c277ff4 perf(cli): check if database is full before launching add menu 2022-12-27 23:50:23 +03:00
H1K0 5782b98550 style(lib): merge similar ifs 2022-12-27 22:59:59 +03:00
H1K0 54e598fd6d perf(lib): check if database is full before adding new record 2022-12-27 22:56:19 +03:00
H1K0 af1dcb2bf2 refactor: reorganize files 2022-12-27 22:47:57 +03:00
H1K0 e39003f4cc docs: add build guide 2022-12-27 21:41:55 +03:00
H1K0 9da4401406 chore: add build script 2022-12-27 21:35:29 +03:00
H1K0 b8a52790eb chore: change minimum required cmake version to 3.22 2022-12-27 20:44:01 +03:00
H1K0 7c773a7398 docs: prepare for 0.1.2-dev release 2022-12-26 11:53:22 +03:00
H1K0 4f57a32c77 perf(cli): correct error handling 2022-12-26 11:49:39 +03:00
H1K0 600b71ebf9 fix(cli): save database after adding/removing kazari 2022-12-26 11:48:32 +03:00
H1K0 219f5de50b fix(cli): print hex tanzaku ID 2022-12-25 13:55:33 +03:00
H1K0 227232d353 chore(ghp): set title and description 2022-12-25 13:48:21 +03:00
H1K0 3c1f0e64f9 chore(ghp): set theme 2022-12-25 13:27:52 +03:00
H1K0 30085b77f3 docs: prepare for 0.1.1-dev release 2022-12-25 13:21:58 +03:00
H1K0 24231a48ef docs(cli): correct help message 2022-12-25 13:09:45 +03:00
H1K0 828e4b5e81 refactor(cli): change config location to /etc/tfm/config 2022-12-25 12:27:41 +03:00
H1K0 bb5e5f209e fix(cli): a little bug fix 2022-12-25 12:27:21 +03:00
H1K0 d148ca3e19 perf(cli): add current database location output in help message 2022-12-25 12:19:01 +03:00
H1K0 72c0fa001f fix(cli): fix config and path problems 2022-12-25 02:57:57 +03:00
H1K0 bb54c2c919 docs: prepare for 0.1.0-dev release 2022-12-25 02:55:57 +03:00
H1K0 094d76cb8c fix(cli): if both -s and -u options are set then unset both of them 2022-12-25 02:11:59 +03:00
H1K0 02aeadf0f7 style(cli): print error message on fail to init/open database 2022-12-25 01:56:12 +03:00
H1K0 535e077b6c style(core): correct one comment in core.h 2022-12-25 01:38:48 +03:00
H1K0 6b5185200e init(cli): add command line interface 2022-12-25 01:21:48 +03:00
H1K0 b5b2cc7fae style(lib): a bit of code cleanup 2022-12-25 01:18:11 +03:00
H1K0 9cbb34512b fix(lib): minor fixes 2022-12-25 00:59:04 +03:00
H1K0 16c577ecb8 fix(lib): remove 0 instead of 1 when trying to add existing kazari 2022-12-25 00:19:23 +03:00
H1K0 abefaed60c fix(lib): fix database weed function 2022-12-24 23:55:07 +03:00
H1K0 7261f88dbd fix(core): reopen file on load/save 2022-12-24 20:46:39 +03:00
H1K0 3bca700ab6 perf(lib): some minor improvements 2022-12-24 20:02:50 +03:00
H1K0 159379cad2 perf(lib): remove all console outputs 2022-12-24 19:34:24 +03:00
H1K0 0547ef6abb perf(core): remove all console outputs 2022-12-24 19:25:07 +03:00
H1K0 f29858361c perf(core): improve remove functions 2022-12-24 17:33:46 +03:00
H1K0 f41a1d66f9 style(lib): correct error messages 2022-12-24 16:45:59 +03:00
H1K0 8dd4361846 fix(core): correct error handling 2022-12-24 00:19:46 +03:00
H1K0 83fe7fcb3c style(lib): a bit of code cleanup 2022-12-24 00:17:45 +03:00
H1K0 98d42f2653 feat(lib): add file path checking 2022-12-23 20:00:04 +03:00
H1K0 629e8e5037 fix(lib): check if kazari exists in kazari add function 2022-12-23 17:35:38 +03:00
H1K0 9ae1a2a8dc fix(lib): check for hole ID in get functions 2022-12-23 17:19:10 +03:00
H1K0 b9873a803c fix(core): a little bug fix 2022-12-23 16:59:55 +03:00
H1K0 d404378251 fix(core): change hole constants to extern class 2022-12-23 16:53:52 +03:00
H1K0 fca32fa558 feat(lib): add get tanzaku list by sasa and get sasa list by tanzaku functions 2022-12-23 16:33:10 +03:00
H1K0 26b4746f41 fix(core): change hole constants to static class 2022-12-23 16:22:48 +03:00
H1K0 f8424a70ca feat(lib): add sasa/tanzaku get functions 2022-12-23 15:45:56 +03:00
H1K0 177799964a fix(lib): update sasa/tanzaku add/remove functions 2022-12-23 15:35:18 +03:00
H1K0 91a53fa703 feat(core): remove tanzaku alias field 2022-12-23 15:14:17 +03:00
H1K0 68c17f0785 style(core): correct error messages 2022-12-23 14:46:06 +03:00
H1K0 1f454f6770 feat(core): leave remove functions only by ID 2022-12-23 14:29:33 +03:00
H1K0 cd9dddc6cd refactor(core): rename "content" field to "database" 2022-12-23 14:23:22 +03:00
H1K0 6739d60c0d refactor(core): define hole sasa/tanzaku/kazari constants 2022-12-23 14:01:17 +03:00
H1K0 cee515da52 refactor(core): change hole ID to -1 2022-12-23 13:21:04 +03:00
H1K0 99d6a2a95c style(core): correct comments in core.h 2022-12-23 13:16:27 +03:00
H1K0 c043bba958 perf(core): add hole management 2022-12-22 17:02:24 +03:00
H1K0 3660e2eb44 fix(core): some significant fixes 2022-12-22 15:19:48 +03:00
H1K0 4b6c328198 style(core): edit comments in core.h 2022-12-22 14:23:46 +03:00
H1K0 e1137785b6 style(core): a bit of code cleanup 2022-12-22 13:51:15 +03:00
H1K0 233e80ceda fix(lib): some fixes 2022-12-22 00:31:05 +03:00
H1K0 8c2eff54a7 feat(lib): add core kazari functions 2022-12-22 00:29:23 +03:00
H1K0 3bcbbdfeaa feat(lib): add core tanzaku functions 2022-12-22 00:26:57 +03:00
H1K0 4f770781df feat(lib): add core sasa functions 2022-12-22 00:22:56 +03:00
H1K0 b6c4837ffe init(lib): add Tanabata FM lib 2022-12-21 22:34:06 +03:00
H1K0 f075769648 style(core): edit comments in core.h 2022-12-21 17:55:36 +03:00
H1K0 6f02cdae10 feat(core): add new remove functions
Now sasa can be removed not only by ID, but also by file path, tanzaku can also be removed by name and alias.
2022-12-21 16:16:46 +03:00
H1K0 587415dc78 init(core): add core 2022-12-21 18:07:59 +03:00
180 changed files with 6212 additions and 7497 deletions
-67
View File
@@ -1,67 +0,0 @@
# =============================================================================
# Tanabata File Manager — .gitattributes
# =============================================================================
# ---------------------------------------------------------------------------
# Line endings: normalize to LF in repo, native on checkout
# ---------------------------------------------------------------------------
* text=auto eol=lf
# ---------------------------------------------------------------------------
# Explicitly text
# ---------------------------------------------------------------------------
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.sql text eol=lf
*.ts text eol=lf
*.js text eol=lf
*.svelte text eol=lf
*.html text eol=lf
*.css text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.env* text eol=lf
*.sh text eol=lf
*.toml text eol=lf
*.xml text eol=lf
*.svg text eol=lf
Dockerfile text eol=lf
Makefile text eol=lf
# ---------------------------------------------------------------------------
# Explicitly binary
# ---------------------------------------------------------------------------
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.otf binary
*.zip binary
*.gz binary
*.tar binary
# ---------------------------------------------------------------------------
# Diff behavior
# ---------------------------------------------------------------------------
*.sql diff=sql
*.go diff=golang
*.css diff=css
*.html diff=html
# ---------------------------------------------------------------------------
# Linguist: set repo language stats correctly
# ---------------------------------------------------------------------------
docs/reference/** linguist-documentation
frontend/static/** linguist-vendored
*.min.js linguist-vendored
*.min.css linguist-vendored
-86
View File
@@ -1,86 +0,0 @@
# =============================================================================
# Tanabata File Manager — .gitignore
# =============================================================================
# ---------------------------------------------------------------------------
# Environment & secrets
# ---------------------------------------------------------------------------
.env
.env.local
.env.*.local
*.pem
*.key
# ---------------------------------------------------------------------------
# OS
# ---------------------------------------------------------------------------
.DS_Store
Thumbs.db
Desktop.ini
*.swp
*.swo
*~
# ---------------------------------------------------------------------------
# IDE
# ---------------------------------------------------------------------------
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.iml
*.sublime-project
*.sublime-workspace
# ---------------------------------------------------------------------------
# Backend (Go)
# ---------------------------------------------------------------------------
backend/tmp/
backend/cmd/server/server
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.prof
coverage.out
coverage.html
# ---------------------------------------------------------------------------
# Frontend (SvelteKit / Node)
# ---------------------------------------------------------------------------
frontend/node_modules/
frontend/.svelte-kit/
frontend/build/
frontend/dist/
frontend/src/lib/api/schema.ts
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
docker-compose.override.yml
# ---------------------------------------------------------------------------
# Data directories (runtime, not in repo)
# ---------------------------------------------------------------------------
data/
*.sqlite
*.sqlite3
# ---------------------------------------------------------------------------
# Misc
# ---------------------------------------------------------------------------
*.log
*.pid
*.seed
# ---------------------------------------------------------------------------
# Reference: exclude vendored libs, keep design sources
# ---------------------------------------------------------------------------
docs/reference/**/bootstrap.min.css
docs/reference/**/bootstrap.min.css.map
docs/reference/**/jquery-*.min.js
docs/reference/**/__pycache__/
docs/reference/**/*.pyc
-55
View File
@@ -1,55 +0,0 @@
# Tanabata File Manager
Multi-user, tag-based web file manager for images and video.
## Architecture
Monorepo: `backend/` (Go) + `frontend/` (SvelteKit).
- Backend: Go + Gin + pgx v5 + goose migrations. Clean Architecture.
- Frontend: SvelteKit SPA + Tailwind CSS + CSS custom properties.
- DB: PostgreSQL 14+.
- Auth: JWT Bearer tokens.
## Key documents (read before coding)
- `openapi.yaml` — full REST API specification (36 paths, 58 operations)
- `docs/GO_PROJECT_STRUCTURE.md` — backend architecture, layer rules, DI pattern
- `docs/FRONTEND_STRUCTURE.md` — frontend architecture, CSS approach, API client
- `docs/Описание.md` — product requirements in Russian
- `backend/migrations/001_init.sql` — database schema (4 schemas, 16 tables)
## Design reference
The `docs/reference/` directory contains the previous Python/Flask version.
Use its visual design as the basis for the new frontend:
- Color palette: #312F45 (bg), #9592B5 (accent), #444455 (tag default), #111118 (elevated)
- Font: Epilogue (variable weight)
- Dark theme is primary
- Mobile-first layout with bottom navbar
- 160×160 thumbnail grid for files
- Colored tag pills
- Floating selection bar for multi-select
## Backend commands
```bash
cd backend
go run ./cmd/server # run dev server
go test ./... # run all tests
```
## Frontend commands
```bash
cd frontend
npm run dev # vite dev server
npm run build # production build
npm run generate:types # regenerate API types from openapi.yaml
```
## Conventions
- Go: gofmt, no global state, context.Context as first param in all service methods
- TypeScript: strict mode, named exports
- SQL: snake_case, all migrations via goose
- API errors: { code, message, details? }
- Git: conventional commits (feat:, fix:, docs:, refactor:)
+57
View File
@@ -0,0 +1,57 @@
cmake_minimum_required(VERSION 3.16)
project(Tanabata
VERSION 2.0.0
HOMEPAGE_URL https://github.com/H1K0/tanabata
LANGUAGES C
)
set(CMAKE_C_STANDARD 99)
set(CORE_SRC
include/core.h
tanabata/core/core_func.h
tanabata/core/sasahyou.c
tanabata/core/sappyou.c
tanabata/core/shoppyou.c
)
set(TANABATA_SRC
${CORE_SRC}
include/tanabata.h
tanabata/lib/database.c
tanabata/lib/sasa.c
tanabata/lib/tanzaku.c
tanabata/lib/kazari.c
)
set(TDBMS_SERVER_SRC
${TANABATA_SRC}
include/tdbms.h
tdbms/server/tdbms-server.c
)
set(TDBMS_CLIENT_SRC
include/tdbms.h
include/tdbms-client.h
tdbms/client/tdbms-client.c
)
set(CLI_SRC
${TANABATA_SRC}
tfm/cli/tfm-cli.c
)
# Tanabata shared lib
add_library(tanabata SHARED ${TANABATA_SRC})
# Tanabata DBMS server
add_executable(tdbms ${TDBMS_SERVER_SRC})
# Tanabata DMBS CLI client app
add_executable(tdb tdbms/cli/tdbms-cli.c ${TDBMS_CLIENT_SRC})
# Tanabata CLI app
add_executable(tfm ${CLI_SRC})
add_executable(test test.c ${TDBMS_CLIENT_SRC})
+64
View File
@@ -0,0 +1,64 @@
<h1 align="center">🎋 Tanabata Project 🎋</h1>
---
[![Release version][release-shield]][release-link]
## Contents
- [About](#about)
- [Glossary](#glossary)
- [Tanabata library](#tanabata-library)
- [Tanabata DBMS](#tanabata-dbms)
- [Tanabata FM](#tanabata-fm)
## About
Tanabata (_jp._ 七夕) is Japanese festival. People generally celebrate this day by writing wishes, sometimes in the form of poetry, on _tanzaku_ (_jp._ 短冊), small pieces of paper, and hanging them on _sasa_ (_jp._ 笹), bamboo. See [this Wikipedia page](https://en.wikipedia.org/wiki/Tanabata) for more information.
Tanabata Project is a software project that will let you enjoy the Tanabata festival. It allows you to store and organize your data as _sasa_ bamboos, on which you can hang almost any number of _tanzaku_, just like adding tags on it.
## Glossary
**Tanabata (_jp._ 七夕)** is a software package project for storing information and organizing it with tags.
**Sasa (_jp._ 笹)** is a file record. It contains 64-bit ID number, the creation timestamp, and the path to the file.
**Tanzaku (_jp._ 短冊)** is a tag record. It contains 64-bit ID number, creation and last modification timestamps, name and description.
**Kazari (_jp._ 飾り)** is a sasa-tanzaku association record. It contains the creation timestamp and associated sasa and tanzaku IDs.
**Hyou (_jp._ 表)** is a table.
**Sasahyou (_jp._ 笹表)** is a table of sasa.
**Sappyou (_jp._ 冊表)** is a table of tanzaku.
**Shoppyou (_jp._ 飾表)** is a table of kazari.
**TDB (Tanabata DataBase)** is a relational database that consists of three tables: _sasahyou_, _sappyou_ and _shoppyou_.
**TDBMS (Tanabata DataBase Management System)** is a management system for TDBs.
**TFM (Tanabata File Manager)** is a TDBMS-powered file manager.
**Tweb (Tanabata web)** is the web user interface for TDBMS and TFM.
## Tanabata library
Tanabata library is a C library for TDB operations. Documentation coming soon...
## Tanabata DBMS
Tanabata Database Management System is the management system for Tanabata databases. Documentation coming soon...
## Tanabata FM
Tanabata File Manager is the TDBMS-powered file manager. Full documentation is [here](docs/fm.md).
---
<h6 align="center"><i>&copy; Masahiko AMANO aka H1K0, 2022—present</i></h6>
[release-shield]: https://img.shields.io/github/release/H1K0/tanabata/all.svg?style=for-the-badge
[release-link]: https://github.com/H1K0/tanabata/releases
+5
View File
@@ -0,0 +1,5 @@
title: Tanabata FM
description: A file manager that will let you enjoy the Tanabata festival!
remote_theme: pages-themes/merlot@v0.2.0
plugins:
- jekyll-remote-theme
-5
View File
@@ -1,5 +0,0 @@
module tanabata/backend
go 1.21
require github.com/google/uuid v1.6.0
-2
View File
@@ -1,2 +0,0 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-19
View File
@@ -1,19 +0,0 @@
package domain
import "github.com/google/uuid"
// ObjectType is a reference entity (file, tag, category, pool).
type ObjectType struct {
ID int16
Name string
}
// Permission represents a per-object access entry for a user.
type Permission struct {
UserID int16
UserName string // denormalized
ObjectTypeID int16
ObjectID uuid.UUID
CanView bool
CanEdit bool
}
-46
View File
@@ -1,46 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// ActionType is a reference entity for auditable user actions.
type ActionType struct {
ID int16
Name string
}
// AuditEntry is a single audit log record.
type AuditEntry struct {
ID int64
UserID int16
UserName string // denormalized
Action string // action type name, e.g. "file_create"
ObjectType *string
ObjectID *uuid.UUID
Details json.RawMessage
PerformedAt time.Time
}
// AuditPage is an offset-based page of audit log entries.
type AuditPage struct {
Items []AuditEntry
Total int
Offset int
Limit int
}
// AuditFilter holds filter parameters for querying the audit log.
type AuditFilter struct {
UserID *int16
Action string
ObjectType string
ObjectID *uuid.UUID
From *time.Time
To *time.Time
Offset int
Limit int
}
-21
View File
@@ -1,21 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Category is a logical grouping of tags.
type Category struct {
ID uuid.UUID
Name string
Notes *string
Color *string // 6-char hex
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
CreatedAt time.Time // extracted from UUID v7
}
-24
View File
@@ -1,24 +0,0 @@
package domain
import "context"
type ctxKey int
const userKey ctxKey = iota
type contextUser struct {
ID int16
IsAdmin bool
}
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context {
return context.WithValue(ctx, userKey, contextUser{ID: userID, IsAdmin: isAdmin})
}
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) {
u, ok := ctx.Value(userKey).(contextUser)
if !ok {
return 0, false
}
return u.ID, u.IsAdmin
}
-13
View File
@@ -1,13 +0,0 @@
package domain
import "errors"
// Sentinel domain errors. Handlers map these to HTTP status codes.
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
ErrValidation = errors.New("validation error")
ErrUnsupportedMIME = errors.New("unsupported MIME type")
)
-54
View File
@@ -1,54 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// MIMEType holds MIME whitelist data.
type MIMEType struct {
ID int16
Name string
Extension string
}
// File represents a managed file record.
type File struct {
ID uuid.UUID
OriginalName *string
MIMEType string // denormalized from core.mime_types
MIMEExtension string // denormalized from core.mime_types
ContentDatetime time.Time
Notes *string
Metadata json.RawMessage
EXIF json.RawMessage
PHash *int64
CreatorID int16
CreatorName string // denormalized from core.users
IsPublic bool
IsDeleted bool
CreatedAt time.Time // extracted from UUID v7
Tags []Tag // loaded with the file
}
// FileListParams holds all parameters for listing/filtering files.
type FileListParams struct {
Filter string
Sort string
Order string
Cursor string
Anchor *uuid.UUID
Direction string // "forward" or "backward"
Limit int
Trash bool
Search string
}
// FilePage is the result of a cursor-based file listing.
type FilePage struct {
Items []File
NextCursor *string
PrevCursor *string
}
-33
View File
@@ -1,33 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Pool is an ordered collection of files.
type Pool struct {
ID uuid.UUID
Name string
Notes *string
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
FileCount int
CreatedAt time.Time // extracted from UUID v7
}
// PoolFile is a File with its ordering position within a pool.
type PoolFile struct {
File
Position int
}
// PoolFilePage is the result of a cursor-based pool file listing.
type PoolFilePage struct {
Items []PoolFile
NextCursor *string
}
-33
View File
@@ -1,33 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// Tag represents a file label.
type Tag struct {
ID uuid.UUID
Name string
Notes *string
Color *string // 6-char hex, e.g. "5DCAA5"
CategoryID *uuid.UUID
CategoryName *string // denormalized
CategoryColor *string // denormalized
Metadata json.RawMessage
CreatorID int16
CreatorName string // denormalized
IsPublic bool
CreatedAt time.Time // extracted from UUID v7
}
// TagRule defines an auto-tagging rule: when WhenTagID is applied,
// ThenTagID is automatically applied as well.
type TagRule struct {
WhenTagID uuid.UUID
ThenTagID uuid.UUID
ThenTagName string // denormalized
IsActive bool
}
-39
View File
@@ -1,39 +0,0 @@
package domain
import "time"
// User is an application user.
type User struct {
ID int16
Name string
Password string // bcrypt hash; only populated when needed for auth
IsAdmin bool
CanCreate bool
IsBlocked bool
}
// Session is an active user session.
type Session struct {
ID int
TokenHash string
UserID int16
UserAgent string
StartedAt time.Time
ExpiresAt *time.Time
LastActivity time.Time
IsCurrent bool // true when this session matches the caller's token
}
// OffsetPage is a generic offset-based page of users.
type UserPage struct {
Items []User
Total int
Offset int
Limit int
}
// SessionList is a list of sessions with a total count.
type SessionList struct {
Items []Session
Total int
}
-61
View File
@@ -1,61 +0,0 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS data;
CREATE SCHEMA IF NOT EXISTS acl;
CREATE SCHEMA IF NOT EXISTS activity;
-- UUID v7 generator
CREATE OR REPLACE FUNCTION public.uuid_v7(cts timestamptz DEFAULT clock_timestamp())
RETURNS uuid LANGUAGE plpgsql AS $$
DECLARE
state text = current_setting('uuidv7.old_tp', true);
old_tp text = split_part(state, ':', 1);
base int = coalesce(nullif(split_part(state, ':', 4), '')::int, (random()*16777215/2-1)::int);
tp text;
entropy text;
seq text = base;
seqn int = split_part(state, ':', 2);
ver text = coalesce(split_part(state, ':', 3), to_hex(8+(random()*3)::int));
BEGIN
base = (random()*16777215/2-1)::int;
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8), 12, '0') || '7';
IF tp IS DISTINCT FROM old_tp THEN
old_tp = tp;
ver = to_hex(8+(random()*3)::int);
base = (random()*16777215/2-1)::int;
seqn = base;
ELSE
seqn = seqn + (random()*1000)::int;
END IF;
PERFORM set_config('uuidv7.old_tp', old_tp||':'||seqn||':'||ver||':'||base, false);
entropy = md5(gen_random_uuid()::text);
seq = lpad(to_hex(seqn), 6, '0');
RETURN (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
substring(entropy from 1 for 12))::uuid;
END;
$$;
-- Extract timestamp from UUID v7
CREATE OR REPLACE FUNCTION public.uuid_extract_timestamp(uuid_val uuid)
RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT to_timestamp(
('x' || left(replace(uuid_val::text, '-', ''), 12))::bit(48)::bigint / 1000.0
);
$$;
-- +goose Down
DROP FUNCTION IF EXISTS public.uuid_extract_timestamp(uuid);
DROP FUNCTION IF EXISTS public.uuid_v7(timestamptz);
DROP SCHEMA IF EXISTS activity;
DROP SCHEMA IF EXISTS acl;
DROP SCHEMA IF EXISTS data;
DROP SCHEMA IF EXISTS core;
DROP EXTENSION IF EXISTS "uuid-ossp";
DROP EXTENSION IF EXISTS pgcrypto;
-36
View File
@@ -1,36 +0,0 @@
-- +goose Up
CREATE TABLE core.users (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(32) NOT NULL,
password text NOT NULL, -- bcrypt hash via pgcrypto
is_admin boolean NOT NULL DEFAULT false,
can_create boolean NOT NULL DEFAULT false,
CONSTRAINT uni__users__name UNIQUE (name)
);
CREATE TABLE core.mime_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(127) NOT NULL,
extension varchar(16) NOT NULL,
CONSTRAINT uni__mime_types__name UNIQUE (name)
);
CREATE TABLE core.object_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(32) NOT NULL,
CONSTRAINT uni__object_types__name UNIQUE (name)
);
COMMENT ON TABLE core.users IS 'Application users';
COMMENT ON TABLE core.mime_types IS 'Whitelist of supported MIME types';
COMMENT ON TABLE core.object_types IS 'Reference: entity types for ACL and audit log';
-- +goose Down
DROP TABLE IF EXISTS core.object_types;
DROP TABLE IF EXISTS core.mime_types;
DROP TABLE IF EXISTS core.users;
-118
View File
@@ -1,118 +0,0 @@
-- +goose Up
CREATE TABLE data.categories (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
color char(6),
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__categories__name UNIQUE (name),
CONSTRAINT chk__categories__color_hex
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);
CREATE TABLE data.tags (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
color char(6),
category_id uuid REFERENCES data.categories(id)
ON UPDATE CASCADE ON DELETE SET NULL,
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__tags__name UNIQUE (name),
CONSTRAINT chk__tags__color_hex
CHECK (color IS NULL OR color ~* '^[A-Fa-f0-9]{6}$')
);
CREATE TABLE data.tag_rules (
when_tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
then_tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
is_active boolean NOT NULL DEFAULT true,
PRIMARY KEY (when_tag_id, then_tag_id)
);
CREATE TABLE data.files (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
original_name varchar(256), -- original filename at upload time
mime_id smallint NOT NULL REFERENCES core.mime_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
notes text,
metadata jsonb, -- user-editable key-value data
exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable)
phash bigint, -- perceptual hash for duplicate detection (future)
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
is_deleted boolean NOT NULL DEFAULT false -- soft delete (trash)
);
CREATE TABLE data.file_tag (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (file_id, tag_id)
);
CREATE TABLE data.pools (
id uuid NOT NULL DEFAULT public.uuid_v7() PRIMARY KEY,
name varchar(256) NOT NULL,
notes text,
metadata jsonb,
creator_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
is_public boolean NOT NULL DEFAULT false,
CONSTRAINT uni__pools__name UNIQUE (name)
);
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
-- insertions without renumbering. Compact when gaps get too small.
CREATE TABLE data.file_pool (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
pool_id uuid NOT NULL REFERENCES data.pools(id)
ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
PRIMARY KEY (file_id, pool_id)
);
COMMENT ON TABLE data.categories IS 'Logical grouping of tags';
COMMENT ON TABLE data.tags IS 'File labels/tags';
COMMENT ON TABLE data.tag_rules IS 'Auto-tagging rules: when when_tag is assigned, then_tag follows';
COMMENT ON TABLE data.files IS 'Managed files; actual content stored on disk as {id}.{ext}';
COMMENT ON TABLE data.file_tag IS 'Many-to-many: files <-> tags';
COMMENT ON TABLE data.pools IS 'Ordered collections of files';
COMMENT ON TABLE data.file_pool IS 'Many-to-many: files <-> pools, with ordering';
COMMENT ON COLUMN data.files.original_name IS 'Original filename at upload time';
COMMENT ON COLUMN data.files.content_datetime IS 'Content datetime (e.g. when photo was taken); falls back to EXIF DateTimeOriginal';
COMMENT ON COLUMN data.files.metadata IS 'User-editable key-value metadata';
COMMENT ON COLUMN data.files.exif IS 'EXIF data extracted at upload time (immutable, system-managed)';
COMMENT ON COLUMN data.files.phash IS 'Perceptual hash for image/video duplicate detection';
COMMENT ON COLUMN data.files.is_deleted IS 'Soft-deleted files (trash); true = in recycle bin';
COMMENT ON COLUMN data.file_pool.position IS 'Manual ordering within pool; uses gapped integers';
-- +goose Down
DROP TABLE IF EXISTS data.file_pool;
DROP TABLE IF EXISTS data.pools;
DROP TABLE IF EXISTS data.file_tag;
DROP TABLE IF EXISTS data.files;
DROP TABLE IF EXISTS data.tag_rules;
DROP TABLE IF EXISTS data.tags;
DROP TABLE IF EXISTS data.categories;
-22
View File
@@ -1,22 +0,0 @@
-- +goose Up
-- If is_public=true on the object, it is accessible to everyone (ACL ignored).
-- If is_public=false, only creator and users with can_view=true see it.
-- Admins bypass all ACL checks.
CREATE TABLE acl.permissions (
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
object_type_id smallint NOT NULL REFERENCES core.object_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_id uuid NOT NULL,
can_view boolean NOT NULL DEFAULT true,
can_edit boolean NOT NULL DEFAULT false,
PRIMARY KEY (user_id, object_type_id, object_id)
);
COMMENT ON TABLE acl.permissions IS 'Per-object permissions (used when is_public=false)';
-- +goose Down
DROP TABLE IF EXISTS acl.permissions;
@@ -1,81 +0,0 @@
-- +goose Up
CREATE TABLE activity.action_types (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name varchar(64) NOT NULL,
CONSTRAINT uni__action_types__name UNIQUE (name)
);
CREATE TABLE activity.sessions (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token_hash text NOT NULL, -- hashed session token
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_agent varchar(256) NOT NULL,
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
expires_at timestamptz,
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
);
CREATE TABLE activity.file_views (
file_id uuid NOT NULL REFERENCES data.files(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
PRIMARY KEY (file_id, viewed_at, user_id)
);
CREATE TABLE activity.pool_views (
pool_id uuid NOT NULL REFERENCES data.pools(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
viewed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
PRIMARY KEY (pool_id, viewed_at, user_id)
);
CREATE TABLE activity.tag_uses (
tag_id uuid NOT NULL REFERENCES data.tags(id)
ON UPDATE CASCADE ON DELETE CASCADE,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
used_at timestamptz NOT NULL DEFAULT statement_timestamp(),
is_included boolean NOT NULL, -- true=included in filter, false=excluded
PRIMARY KEY (tag_id, used_at, user_id)
);
CREATE TABLE activity.audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id smallint NOT NULL REFERENCES core.users(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
action_type_id smallint NOT NULL REFERENCES activity.action_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_type_id smallint REFERENCES core.object_types(id)
ON UPDATE CASCADE ON DELETE RESTRICT,
object_id uuid,
details jsonb, -- action-specific payload
performed_at timestamptz NOT NULL DEFAULT statement_timestamp()
);
COMMENT ON TABLE activity.action_types IS 'Reference: types of auditable user actions';
COMMENT ON TABLE activity.sessions IS 'Active user sessions';
COMMENT ON TABLE activity.file_views IS 'File view history';
COMMENT ON TABLE activity.pool_views IS 'Pool view history';
COMMENT ON TABLE activity.tag_uses IS 'Tag usage in filters';
COMMENT ON TABLE activity.audit_log IS 'Unified audit trail for all user actions';
-- +goose Down
DROP TABLE IF EXISTS activity.audit_log;
DROP TABLE IF EXISTS activity.tag_uses;
DROP TABLE IF EXISTS activity.pool_views;
DROP TABLE IF EXISTS activity.file_views;
DROP TABLE IF EXISTS activity.sessions;
DROP TABLE IF EXISTS activity.action_types;
-87
View File
@@ -1,87 +0,0 @@
-- +goose Up
-- core
CREATE INDEX idx__users__name ON core.users USING hash (name);
-- data.categories
CREATE INDEX idx__categories__creator_id ON data.categories USING hash (creator_id);
-- data.tags
CREATE INDEX idx__tags__category_id ON data.tags USING hash (category_id);
CREATE INDEX idx__tags__creator_id ON data.tags USING hash (creator_id);
-- data.tag_rules
CREATE INDEX idx__tag_rules__when ON data.tag_rules USING hash (when_tag_id);
CREATE INDEX idx__tag_rules__then ON data.tag_rules USING hash (then_tag_id);
-- data.files
CREATE INDEX idx__files__mime_id ON data.files USING hash (mime_id);
CREATE INDEX idx__files__creator_id ON data.files USING hash (creator_id);
CREATE INDEX idx__files__content_datetime ON data.files USING btree (content_datetime DESC NULLS LAST);
CREATE INDEX idx__files__is_deleted ON data.files USING btree (is_deleted) WHERE is_deleted = true;
CREATE INDEX idx__files__phash ON data.files USING btree (phash) WHERE phash IS NOT NULL;
-- data.file_tag
CREATE INDEX idx__file_tag__tag_id ON data.file_tag USING hash (tag_id);
CREATE INDEX idx__file_tag__file_id ON data.file_tag USING hash (file_id);
-- data.pools
CREATE INDEX idx__pools__creator_id ON data.pools USING hash (creator_id);
-- data.file_pool
CREATE INDEX idx__file_pool__pool_id ON data.file_pool USING hash (pool_id);
CREATE INDEX idx__file_pool__file_id ON data.file_pool USING hash (file_id);
-- acl.permissions
CREATE INDEX idx__acl__object ON acl.permissions USING btree (object_type_id, object_id);
CREATE INDEX idx__acl__user ON acl.permissions USING hash (user_id);
-- activity.sessions
CREATE INDEX idx__sessions__user_id ON activity.sessions USING hash (user_id);
CREATE INDEX idx__sessions__token_hash ON activity.sessions USING hash (token_hash);
-- activity.file_views
CREATE INDEX idx__file_views__user_id ON activity.file_views USING hash (user_id);
-- activity.pool_views
CREATE INDEX idx__pool_views__user_id ON activity.pool_views USING hash (user_id);
-- activity.tag_uses
CREATE INDEX idx__tag_uses__user_id ON activity.tag_uses USING hash (user_id);
-- activity.audit_log
CREATE INDEX idx__audit_log__user_id ON activity.audit_log USING hash (user_id);
CREATE INDEX idx__audit_log__action_type_id ON activity.audit_log USING hash (action_type_id);
CREATE INDEX idx__audit_log__object ON activity.audit_log USING btree (object_type_id, object_id)
WHERE object_id IS NOT NULL;
CREATE INDEX idx__audit_log__performed_at ON activity.audit_log USING btree (performed_at DESC);
-- +goose Down
DROP INDEX IF EXISTS activity.idx__audit_log__performed_at;
DROP INDEX IF EXISTS activity.idx__audit_log__object;
DROP INDEX IF EXISTS activity.idx__audit_log__action_type_id;
DROP INDEX IF EXISTS activity.idx__audit_log__user_id;
DROP INDEX IF EXISTS activity.idx__tag_uses__user_id;
DROP INDEX IF EXISTS activity.idx__pool_views__user_id;
DROP INDEX IF EXISTS activity.idx__file_views__user_id;
DROP INDEX IF EXISTS activity.idx__sessions__token_hash;
DROP INDEX IF EXISTS activity.idx__sessions__user_id;
DROP INDEX IF EXISTS acl.idx__acl__user;
DROP INDEX IF EXISTS acl.idx__acl__object;
DROP INDEX IF EXISTS data.idx__file_pool__file_id;
DROP INDEX IF EXISTS data.idx__file_pool__pool_id;
DROP INDEX IF EXISTS data.idx__pools__creator_id;
DROP INDEX IF EXISTS data.idx__file_tag__file_id;
DROP INDEX IF EXISTS data.idx__file_tag__tag_id;
DROP INDEX IF EXISTS data.idx__files__phash;
DROP INDEX IF EXISTS data.idx__files__is_deleted;
DROP INDEX IF EXISTS data.idx__files__content_datetime;
DROP INDEX IF EXISTS data.idx__files__creator_id;
DROP INDEX IF EXISTS data.idx__files__mime_id;
DROP INDEX IF EXISTS data.idx__tag_rules__then;
DROP INDEX IF EXISTS data.idx__tag_rules__when;
DROP INDEX IF EXISTS data.idx__tags__creator_id;
DROP INDEX IF EXISTS data.idx__tags__category_id;
DROP INDEX IF EXISTS data.idx__categories__creator_id;
DROP INDEX IF EXISTS core.idx__users__name;
-32
View File
@@ -1,32 +0,0 @@
-- +goose Up
INSERT INTO core.object_types (name) VALUES
('file'), ('tag'), ('category'), ('pool');
INSERT INTO activity.action_types (name) VALUES
-- Auth
('user_login'), ('user_logout'),
-- Files
('file_create'), ('file_edit'), ('file_delete'), ('file_restore'),
('file_permanent_delete'), ('file_replace'),
-- Tags
('tag_create'), ('tag_edit'), ('tag_delete'),
-- Categories
('category_create'), ('category_edit'), ('category_delete'),
-- Pools
('pool_create'), ('pool_edit'), ('pool_delete'),
-- Relations
('file_tag_add'), ('file_tag_remove'),
('file_pool_add'), ('file_pool_remove'),
-- ACL
('acl_change'),
-- Admin
('user_create'), ('user_delete'), ('user_block'), ('user_unblock'),
('user_role_change'),
-- Sessions
('session_terminate');
-- +goose Down
DELETE FROM activity.action_types;
DELETE FROM core.object_types;
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
BUILD_DIR=./build/
TARGET=all
while getopts "b:t:" option; do
case $option in
b) BUILD_DIR=$OPTARG ;;
t) TARGET=$OPTARG ;;
?)
echo "Error: invalid option"
exit 1
;;
esac
done
if [ ! -d "$BUILD_DIR" ]; then
mkdir "$BUILD_DIR"
if [ ! -d "$BUILD_DIR" ]; then
echo "Error: could not create folder '$BUILD_DIR'"
exit 1
fi
fi
cmake -S . -B "$BUILD_DIR" && cmake --build "$BUILD_DIR" --target "$TARGET"
-383
View File
@@ -1,383 +0,0 @@
# Tanabata File Manager — Frontend Structure
## Stack
- **Framework**: SvelteKit (SPA mode, `ssr: false`)
- **Language**: TypeScript
- **CSS**: Tailwind CSS + CSS custom properties (hybrid)
- **API types**: Auto-generated via openapi-typescript
- **PWA**: Service worker + web manifest
- **Font**: Epilogue (variable weight)
- **Package manager**: npm
## Monorepo Layout
```
tanabata/
├── backend/ ← Go project (go.mod in here)
│ ├── cmd/
│ ├── internal/
│ ├── migrations/
│ ├── go.mod
│ └── go.sum
├── frontend/ ← SvelteKit project (package.json in here)
│ └── (see below)
├── openapi.yaml ← Shared API contract (root level)
├── docker-compose.yml
├── Dockerfile
├── .env.example
└── README.md
```
`openapi.yaml` lives at repository root — both backend and frontend
reference it. The frontend generates types from it; the backend
validates its handlers against it.
## Frontend Directory Layout
```
frontend/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── postcss.config.js
├── src/
│ ├── app.html # Shell HTML (PWA meta, font preload)
│ ├── app.css # Tailwind directives + CSS custom properties
│ ├── hooks.server.ts # Server hooks (not used in SPA mode)
│ ├── hooks.client.ts # Client hooks (global error handling)
│ │
│ ├── lib/ # Shared code ($lib/ alias)
│ │ │
│ │ ├── api/ # API client layer
│ │ │ ├── client.ts # Base fetch wrapper: auth headers, token refresh,
│ │ │ │ # error parsing, base URL
│ │ │ ├── files.ts # listFiles, getFile, uploadFile, deleteFile, etc.
│ │ │ ├── tags.ts # listTags, createTag, getTag, updateTag, etc.
│ │ │ ├── categories.ts # Category API functions
│ │ │ ├── pools.ts # Pool API functions
│ │ │ ├── auth.ts # login, logout, refresh, listSessions
│ │ │ ├── acl.ts # getPermissions, setPermissions
│ │ │ ├── users.ts # getMe, updateMe, admin user CRUD
│ │ │ ├── audit.ts # queryAuditLog
│ │ │ ├── schema.ts # AUTO-GENERATED from openapi.yaml (do not edit)
│ │ │ └── types.ts # Friendly type aliases:
│ │ │ # export type File = components["schemas"]["File"]
│ │ │ # export type Tag = components["schemas"]["Tag"]
│ │ │
│ │ ├── components/ # Reusable UI components
│ │ │ │
│ │ │ ├── layout/ # App shell
│ │ │ │ ├── Navbar.svelte # Bottom navigation bar (mobile-first)
│ │ │ │ ├── Header.svelte # Section header with sorting controls
│ │ │ │ ├── SelectionBar.svelte # Floating bar for multi-select actions
│ │ │ │ └── Loader.svelte # Full-screen loading overlay
│ │ │ │
│ │ │ ├── file/ # File-related components
│ │ │ │ ├── FileGrid.svelte # Thumbnail grid with infinite scroll
│ │ │ │ ├── FileCard.svelte # Single thumbnail (160×160, selectable)
│ │ │ │ ├── FileViewer.svelte # Full-screen preview with prev/next navigation
│ │ │ │ ├── FileUpload.svelte # Upload form + drag-and-drop zone
│ │ │ │ ├── FileDetail.svelte # Metadata editor (notes, datetime, tags)
│ │ │ │ └── FilterBar.svelte # DSL filter builder UI
│ │ │ │
│ │ │ ├── tag/ # Tag-related components
│ │ │ │ ├── TagBadge.svelte # Colored pill with tag name
│ │ │ │ ├── TagPicker.svelte # Searchable tag selector (add/remove)
│ │ │ │ ├── TagList.svelte # Tag grid for section view
│ │ │ │ └── TagRuleEditor.svelte # Auto-tag rule management
│ │ │ │
│ │ │ ├── pool/ # Pool-related components
│ │ │ │ ├── PoolCard.svelte # Pool preview card
│ │ │ │ ├── PoolFileList.svelte # Ordered file list with drag reorder
│ │ │ │ └── PoolDetail.svelte # Pool metadata editor
│ │ │ │
│ │ │ ├── acl/ # Access control components
│ │ │ │ └── PermissionEditor.svelte # User permission grid
│ │ │ │
│ │ │ └── common/ # Shared primitives
│ │ │ ├── Button.svelte
│ │ │ ├── Modal.svelte
│ │ │ ├── ConfirmDialog.svelte
│ │ │ ├── Toast.svelte
│ │ │ ├── InfiniteScroll.svelte
│ │ │ ├── Pagination.svelte
│ │ │ ├── SortDropdown.svelte
│ │ │ ├── SearchInput.svelte
│ │ │ ├── ColorPicker.svelte
│ │ │ ├── Checkbox.svelte # Three-state: checked, unchecked, partial
│ │ │ └── EmptyState.svelte
│ │ │
│ │ ├── stores/ # Svelte stores (global state)
│ │ │ ├── auth.ts # Current user, JWT tokens, isAuthenticated
│ │ │ ├── selection.ts # Selected item IDs, selection mode toggle
│ │ │ ├── sorting.ts # Per-section sort key + order (persisted to localStorage)
│ │ │ ├── theme.ts # Dark/light mode (persisted, respects prefers-color-scheme)
│ │ │ └── toast.ts # Notification queue (success, error, info)
│ │ │
│ │ └── utils/ # Pure helper functions
│ │ ├── format.ts # formatDate, formatFileSize, formatDuration
│ │ ├── dsl.ts # Filter DSL builder: UI state → query string
│ │ ├── pwa.ts # PWA reset, cache clear, update prompt
│ │ └── keyboard.ts # Keyboard shortcut helpers (Ctrl+A, Escape, etc.)
│ │
│ ├── routes/ # SvelteKit file-based routing
│ │ │
│ │ ├── +layout.svelte # Root layout: Navbar, theme wrapper, toast container
│ │ ├── +layout.ts # Root load: auth guard → redirect to /login if no token
│ │ │
│ │ ├── +page.svelte # / → redirect to /files
│ │ │
│ │ ├── login/
│ │ │ └── +page.svelte # Login form (decorative Tanabata images)
│ │ │
│ │ ├── files/
│ │ │ ├── +page.svelte # File grid: filter bar, sort, multi-select, upload
│ │ │ ├── +page.ts # Load: initial file list (cursor page)
│ │ │ ├── [id]/
│ │ │ │ ├── +page.svelte # File view: preview, metadata, tags, ACL
│ │ │ │ └── +page.ts # Load: file detail + tags
│ │ │ └── trash/
│ │ │ ├── +page.svelte # Trash: restore / permanent delete
│ │ │ └── +page.ts
│ │ │
│ │ ├── tags/
│ │ │ ├── +page.svelte # Tag list: search, sort, multi-select
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte # Create tag form
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Tag detail: edit, category, rules, parent tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── categories/
│ │ │ ├── +page.svelte # Category list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Category detail: edit, view tags
│ │ │ └── +page.ts
│ │ │
│ │ ├── pools/
│ │ │ ├── +page.svelte # Pool list
│ │ │ ├── +page.ts
│ │ │ ├── new/
│ │ │ │ └── +page.svelte
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # Pool detail: files (reorderable), filter, edit
│ │ │ └── +page.ts
│ │ │
│ │ ├── settings/
│ │ │ ├── +page.svelte # Profile: name, password, active sessions
│ │ │ └── +page.ts
│ │ │
│ │ └── admin/
│ │ ├── +layout.svelte # Admin layout: restrict to is_admin
│ │ ├── users/
│ │ │ ├── +page.svelte # User management list
│ │ │ ├── +page.ts
│ │ │ └── [id]/
│ │ │ ├── +page.svelte # User detail: role, block/unblock
│ │ │ └── +page.ts
│ │ └── audit/
│ │ ├── +page.svelte # Audit log with filters
│ │ └── +page.ts
│ │
│ └── service-worker.ts # PWA: offline cache for pinned files, app shell caching
└── static/
├── favicon.png
├── favicon.ico
├── manifest.webmanifest # PWA manifest (name, icons, theme_color)
├── images/
│ ├── tanabata-left.png # Login page decorations (from current design)
│ ├── tanabata-right.png
│ └── icons/ # PWA icons (192×192, 512×512, etc.)
└── fonts/
└── Epilogue-VariableFont_wght.ttf
```
## Key Architecture Decisions
### CSS Hybrid: Tailwind + Custom Properties
Theme colors defined as CSS custom properties in `app.css`:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-bg-primary: #312F45;
--color-bg-secondary: #181721;
--color-bg-elevated: #111118;
--color-accent: #9592B5;
--color-accent-hover: #7D7AA4;
--color-text-primary: #f0f0f0;
--color-text-muted: #9999AD;
--color-danger: #DB6060;
--color-info: #4DC7ED;
--color-warning: #F5E872;
--color-tag-default: #444455;
}
:root[data-theme="light"] {
--color-bg-primary: #f5f5f5;
--color-bg-secondary: #ffffff;
/* ... */
}
```
Tailwind references them in `tailwind.config.ts`:
```ts
export default {
theme: {
extend: {
colors: {
bg: {
primary: 'var(--color-bg-primary)',
secondary: 'var(--color-bg-secondary)',
elevated: 'var(--color-bg-elevated)',
},
accent: {
DEFAULT: 'var(--color-accent)',
hover: 'var(--color-accent-hover)',
},
// ...
},
fontFamily: {
sans: ['Epilogue', 'sans-serif'],
},
},
},
darkMode: 'class', // controlled via data-theme attribute
};
```
Usage in components: `<div class="bg-bg-primary text-text-primary rounded-xl p-4">`.
Complex cases use scoped `<style>` inside `.svelte` files.
### API Client Pattern
`client.ts` — thin wrapper around fetch:
```ts
// $lib/api/client.ts
import { authStore } from '$lib/stores/auth';
const BASE = '/api/v1';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = get(authStore).accessToken;
const res = await fetch(BASE + path, {
...init,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...init?.headers,
},
});
if (res.status === 401) {
// attempt refresh, retry once
}
if (!res.ok) {
const err = await res.json();
throw new ApiError(res.status, err.code, err.message, err.details);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: 'POST', body: formData, headers: {} }),
};
```
Domain-specific modules use it:
```ts
// $lib/api/files.ts
import { api } from './client';
import type { File, FileCursorPage } from './types';
export function listFiles(params: Record<string, string>) {
const qs = new URLSearchParams(params).toString();
return api.get<FileCursorPage>(`/files?${qs}`);
}
export function uploadFile(formData: FormData) {
return api.upload<File>('/files', formData);
}
```
### Type Generation
Script in `package.json`:
```json
{
"scripts": {
"generate:types": "openapi-typescript ../openapi.yaml -o src/lib/api/schema.ts",
"dev": "npm run generate:types && vite dev",
"build": "npm run generate:types && vite build"
}
}
```
Friendly aliases in `types.ts`:
```ts
import type { components } from './schema';
export type File = components['schemas']['File'];
export type Tag = components['schemas']['Tag'];
export type Category = components['schemas']['Category'];
export type Pool = components['schemas']['Pool'];
export type FileCursorPage = components['schemas']['FileCursorPage'];
export type TagOffsetPage = components['schemas']['TagOffsetPage'];
export type Error = components['schemas']['Error'];
// ...
```
### SPA Mode
`svelte.config.js`:
```js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ fallback: 'index.html' }),
// SPA: all routes handled client-side
},
};
```
The Go backend serves `index.html` for all non-API routes (SPA fallback).
In development, Vite dev server proxies `/api` to the Go backend.
### PWA
`service-worker.ts` handles:
- App shell caching (HTML, CSS, JS, fonts)
- User-pinned file caching (explicit, via UI button)
- Cache versioning and cleanup on update
- Reset function (clear all caches except pinned files)
-320
View File
@@ -1,320 +0,0 @@
# Tanabata File Manager — Go Project Structure
## Stack
- **Router**: Gin
- **Database**: pgx v5 (pgxpool)
- **Migrations**: goose v3 + go:embed (auto-migrate on startup)
- **Auth**: JWT (golang-jwt/jwt/v5)
- **Config**: environment variables via .env (joho/godotenv)
- **Logging**: slog (stdlib, Go 1.21+)
- **Validation**: go-playground/validator/v10
- **EXIF**: rwcarlsen/goexif or dsoprea/go-exif
- **Image processing**: disintegration/imaging (thumbnails, previews)
- **Architecture**: Clean Architecture (domain → service → repository/handler)
## Monorepo Layout
```
tanabata/
├── backend/ ← Go project
├── frontend/ ← SvelteKit project
├── openapi.yaml ← Shared API contract
├── docker-compose.yml
├── Dockerfile
├── .env.example
└── README.md
```
## Backend Directory Layout
```
backend/
├── cmd/
│ └── server/
│ └── main.go # Entrypoint: config → DB → migrate → wire → run
├── internal/
│ │
│ ├── domain/ # Pure business entities & value objects
│ │ ├── file.go # File, FileFilter, FilePage
│ │ ├── tag.go # Tag, TagRule
│ │ ├── category.go # Category
│ │ ├── pool.go # Pool, PoolFile
│ │ ├── user.go # User, Session
│ │ ├── acl.go # Permission, ObjectType
│ │ ├── audit.go # AuditEntry, ActionType
│ │ └── errors.go # Domain error types (ErrNotFound, ErrForbidden, etc.)
│ │
│ ├── port/ # Interfaces (ports) — contracts between layers
│ │ ├── repository.go # FileRepo, TagRepo, CategoryRepo, PoolRepo,
│ │ │ # UserRepo, SessionRepo, ACLRepo, AuditRepo,
│ │ │ # MimeRepo, TagRuleRepo
│ │ └── storage.go # FileStorage interface (disk operations)
│ │
│ ├── service/ # Business logic (use cases)
│ │ ├── file_service.go # Upload, update, delete, trash/restore, replace,
│ │ │ # import, filter/list, duplicate detection
│ │ ├── tag_service.go # CRUD + auto-tag application logic
│ │ ├── category_service.go # CRUD (thin, delegates to repo + ACL + audit)
│ │ ├── pool_service.go # CRUD + file ordering, add/remove files
│ │ ├── auth_service.go # Login, logout, JWT issue/refresh, session management
│ │ ├── acl_service.go # Permission checks, grant/revoke
│ │ ├── audit_service.go # Log actions, query audit log
│ │ └── user_service.go # Profile update, admin CRUD, block/unblock
│ │
│ ├── handler/ # HTTP layer (Gin handlers)
│ │ ├── router.go # Route registration, middleware wiring
│ │ ├── middleware.go # Auth middleware (JWT extraction → context)
│ │ ├── request.go # Common request parsing helpers
│ │ ├── response.go # Error/success response builders
│ │ ├── file_handler.go # /files endpoints
│ │ ├── tag_handler.go # /tags endpoints
│ │ ├── category_handler.go # /categories endpoints
│ │ ├── pool_handler.go # /pools endpoints
│ │ ├── auth_handler.go # /auth endpoints
│ │ ├── acl_handler.go # /acl endpoints
│ │ ├── user_handler.go # /users endpoints
│ │ └── audit_handler.go # /audit endpoints
│ │
│ ├── db/ # Database adapters
│ │ ├── db.go # Common helpers: pagination, repo factory, transactor base
│ │ └── postgres/ # PostgreSQL implementation
│ │ ├── postgres.go # pgxpool init, tx-from-context helpers
│ │ ├── file_repo.go # FileRepo implementation
│ │ ├── tag_repo.go # TagRepo + TagRuleRepo implementation
│ │ ├── category_repo.go # CategoryRepo implementation
│ │ ├── pool_repo.go # PoolRepo implementation
│ │ ├── user_repo.go # UserRepo implementation
│ │ ├── session_repo.go # SessionRepo implementation
│ │ ├── acl_repo.go # ACLRepo implementation
│ │ ├── audit_repo.go # AuditRepo implementation
│ │ ├── mime_repo.go # MimeRepo implementation
│ │ └── filter_parser.go # DSL → SQL WHERE clause builder
│ │
│ ├── storage/ # File storage adapter
│ │ └── disk.go # FileStorage implementation (read/write/delete on disk)
│ │
│ └── config/ # Configuration
│ └── config.go # Struct + loader from env vars
├── migrations/ # SQL migration files (goose format)
│ ├── 001_init_schemas.sql
│ ├── 002_core_tables.sql
│ ├── 003_data_tables.sql
│ ├── 004_acl_tables.sql
│ ├── 005_activity_tables.sql
│ ├── 006_indexes.sql
│ └── 007_seed_data.sql
├── go.mod
└── go.sum
```
## Layer Dependency Rules
```
handler → service → port (interfaces) ← db/postgres / storage
domain (entities, value objects, errors)
```
- **domain/**: zero imports from other internal packages. Only stdlib.
- **port/**: imports only domain/. Defines interfaces.
- **service/**: imports domain/ and port/. Never imports db/ or handler/.
- **handler/**: imports domain/ and service/. Never imports db/.
- **db/postgres/**: imports domain/, port/, and db/ (common helpers). Implements port interfaces.
- **db/**: imports domain/ and port/. Shared utilities for all DB adapters.
- **storage/**: imports domain/ and port/. Implements FileStorage.
No layer may import a layer above it. No circular dependencies.
## Key Design Decisions
### Dependency Injection (Wiring)
Manual wiring in `cmd/server/main.go`. No DI frameworks.
```go
// Pseudocode
pool := postgres.NewPool(cfg.DatabaseURL)
goose.Up(pool, migrations)
// Repos (all from internal/db/postgres/)
fileRepo := postgres.NewFileRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
// ...
// Storage
diskStore := storage.NewDiskStorage(cfg.FilesPath)
// Services
aclSvc := service.NewACLService(aclRepo, objectTypeRepo)
auditSvc := service.NewAuditService(auditRepo, actionTypeRepo)
fileSvc := service.NewFileService(fileRepo, mimeRepo, tagRepo, diskStore, aclSvc, auditSvc)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc)
// ...
// Handlers
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
// ...
router := handler.NewRouter(cfg, fileHandler, tagHandler, ...)
router.Run(cfg.ListenAddr)
```
### Context Propagation
Every service method receives `context.Context` as the first argument.
The handler extracts user info from JWT (via middleware) and puts it
into context. Services read the current user from context for ACL checks
and audit logging.
```go
// middleware.go
func (m *AuthMiddleware) Handle(c *gin.Context) {
claims := parseJWT(c.GetHeader("Authorization"))
ctx := domain.WithUser(c.Request.Context(), claims.UserID, claims.IsAdmin)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
// domain/context.go
type ctxKey int
const userKey ctxKey = iota
func WithUser(ctx context.Context, userID int16, isAdmin bool) context.Context { ... }
func UserFromContext(ctx context.Context) (userID int16, isAdmin bool) { ... }
```
### Transaction Management
Repository interfaces include a `Transactor`:
```go
// port/repository.go
type Transactor interface {
WithTx(ctx context.Context, fn func(ctx context.Context) error) error
}
```
The postgres implementation wraps `pgxpool.Pool.BeginTx`. Inside `fn`,
all repo calls use the transaction from context. This allows services
to compose multiple repo calls in a single transaction:
```go
// service/file_service.go
func (s *FileService) Upload(ctx context.Context, input UploadInput) (*domain.File, error) {
return s.tx.WithTx(ctx, func(ctx context.Context) error {
file, err := s.fileRepo.Create(ctx, ...) // uses tx
if err != nil { return err }
for _, tagID := range input.TagIDs {
s.tagRepo.AddFileTag(ctx, file.ID, tagID) // same tx
}
s.auditRepo.Log(ctx, ...) // same tx
return nil
})
}
```
### ACL Check Pattern
ACL logic is centralized in `ACLService`. Other services call it before
any data mutation or retrieval:
```go
// service/acl_service.go
func (s *ACLService) CanView(ctx context.Context, objectType string, objectID uuid.UUID) error {
userID, isAdmin := domain.UserFromContext(ctx)
if isAdmin { return nil }
// Check is_public on the object
// If not public, check creator_id == userID
// If not creator, check acl.permissions
// Return domain.ErrForbidden if none match
}
```
### Error Mapping
Domain errors → HTTP status codes (handled in handler/response.go):
| Domain Error | HTTP Status | Error Code |
|-----------------------|-------------|-------------------|
| ErrNotFound | 404 | not_found |
| ErrForbidden | 403 | forbidden |
| ErrUnauthorized | 401 | unauthorized |
| ErrConflict | 409 | conflict |
| ErrValidation | 400 | validation_error |
| ErrUnsupportedMIME | 415 | unsupported_mime |
| (unexpected) | 500 | internal_error |
### Filter DSL
The DSL parser lives in `db/postgres/filter_parser.go` because it produces
SQL WHERE clauses — it is a PostgreSQL-specific adapter concern.
The service layer passes the raw DSL string to the repository; the
repository parses it and builds the query.
For a different DBMS, a corresponding parser would live in
`db/<dbms>/filter_parser.go`.
The interface:
```go
// port/repository.go
type FileRepo interface {
List(ctx context.Context, params FileListParams) (*domain.FilePage, error)
// ...
}
// domain/file.go
type FileListParams struct {
Filter string // raw DSL string
Sort string
Order string
Cursor string
Anchor *uuid.UUID
Direction string // "forward" or "backward"
Limit int
Trash bool
Search string
}
```
### JWT Structure
```go
type Claims struct {
jwt.RegisteredClaims
UserID int16 `json:"uid"`
IsAdmin bool `json:"adm"`
SessionID int `json:"sid"`
}
```
Access token: short-lived (15 min). Refresh token: long-lived (30 days),
stored as hash in `activity.sessions.token_hash`.
### Configuration (.env)
```env
# Server
LISTEN_ADDR=:8080
JWT_SECRET=<random-32-bytes>
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
# Database
DATABASE_URL=postgres://user:pass@host:5432/tanabata?sslmode=disable
# Storage
FILES_PATH=/data/files
THUMBS_CACHE_PATH=/data/thumbs
# Thumbnails
THUMB_WIDTH=160
THUMB_HEIGHT=160
PREVIEW_WIDTH=1920
PREVIEW_HEIGHT=1080
# Import
IMPORT_PATH=/data/import
```
+56
View File
@@ -0,0 +1,56 @@
# Tanabata File Manager
## Usage
### Command Line Interface
Build the CLI app using `./build.sh -t tfm [-b <build_dir>]`. For better experience, you can move the executable to the `/usr/bin/` directory (totally safe unless you have another app named `tfm`) or add the directory with it to `PATH`.
Then just open the terminal and run `tfm -h`. If you are running it for the first time, run it with `sudo` or manually create the `/etc/tanabata/` directory and check its permissions. This is the directory where Tanabata programs store their configuration files. If everything is set up properly, you should get the following help message.
```
(C) Masahiko AMANO aka H1K0, 2022—present
(https://github.com/H1K0/tanabata)
Usage:
tfm <options>
Options:
-h Print this help and exit
-I <dir> Initialize new Tanabata database in directory <dir>
-O <dir> Open existing Tanabata database from directory <dir>
-i View database info
-s Set or add
-u Unset or remove
-e Edit or update
-f <sasa_id or path> File-sasa menu
-t <tanzaku_id or name> Tanzaku menu
-c <sasa_id>-<tanzaku_id> Kazari menu (can only be used with the '-s' or '-u' option)
-w Weed (defragment) database
No database connected
```
So, let's take a look at each option.
Using the `-I <dir>` option, you can initialize an empty TFM database in the specified directory. The app creates empty sasahyou, sappyou and shoppyou files and saves the directory path to the configuration file. The new database will be used the next time you run the app until you change it.
Using the `-O <dir>` option, you can open an existing TFM database in the specified directory. The app checks if the directory contains sasahyou, sappyou and shoppyou files, and if they exist and are valid, saves the directory path to the configuration file. The new database will be used the next time you run the app until you change it.
Using the `-i` option, you can get info about your database. When your hyous were created and last modified, how many records and holes they have, and so on.
Using the `-s` option, you can add new sasa, tanzaku, or kazari.
Using the `-u` option, you can remove sasa, tanzaku, or kazari.
Using the `-e` option, you can update sasa file path or tanzaku name or description. If you want to keep the current value of a field (for example, if you want to change the description of tanzaku while keeping its name), just leave its line blank.
Using the `-f` option, you can manage your sasa. It takes sasa ID when used alone or with the `-u` or `-e` option or target file path when used with the `-s` option. If you want to view the list of all sasa, pass `.` as an argument. For example, `tfm -f 2d` prints the info about sasa with ID `2d` and `tfm -sf path/to/file` adds a new file to the database.
Using the `-t` option, you can manage your tanzaku. It takes tanzaku ID when used alone or with the `-u` or `-e` option or the name of new tanzaku when used with the `-s` option. If you want to view the list of all tanzaku, pass `.` as an argument. For example, `tfm -t c4` prints the info about sasa with ID `c4` and `tfm -st "New tag name"` adds a new tanzaku to the database.
The `-c` option can be used only with the `-s` or `-u` option. It takes the IDs of sasa and tanzaku to link/unlink separated with a hyphen. For example, `tfm -sc 10-4d` links sasa with ID `10` and tanzaku with ID `4d`.
Using the `-w` option, you can _weed_ the database. It's like defragmentation. For example, if you had 4 files with sasa IDs 0, 1, 2, 3 in your database and removed the 1st one, then your database would only have sasa IDs 0, 2, 3 and ID 1 would be a _hole_. Weeding fixes this hole by changing sasa ID 2 to 1, 3 to 2, and updating all related kazari, so for large databases this can take a while.
Using the `-V` option, you just get the current version of TFM.
-374
View File
@@ -1,374 +0,0 @@
from configparser import ConfigParser
from psycopg2.pool import ThreadedConnectionPool
from psycopg2.extras import RealDictCursor
from contextlib import contextmanager
from os import access, W_OK, makedirs, chmod, system
from os.path import isfile, join, basename
from shutil import move
from magic import Magic
from preview_generator.manager import PreviewManager
conf = None
mage = None
previewer = None
db_pool = None
DEFAULT_SORTING = {
"files": {
"key": "created",
"asc": False
},
"tags": {
"key": "created",
"asc": False
},
"categories": {
"key": "created",
"asc": False
},
"pools": {
"key": "created",
"asc": False
},
}
def Initialize(conf_path="/etc/tfm/tfm.conf"):
global mage, previewer
load_config(conf_path)
mage = Magic(mime=True)
previewer = PreviewManager(conf["Paths"]["Thumbs"])
db_connect(conf["DB.limits"]["MinimumConnections"], conf["DB.limits"]["MaximumConnections"], **conf["DB.params"])
def load_config(path):
global conf
conf = ConfigParser()
conf.read(path)
def db_connect(minconn, maxconn, **kwargs):
global db_pool
db_pool = ThreadedConnectionPool(minconn, maxconn, **kwargs)
@contextmanager
def _db_cursor():
global db_pool
try:
conn = db_pool.getconn()
except:
raise RuntimeError("Database not connected")
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
yield cur
conn.commit()
except:
conn.rollback()
raise
finally:
db_pool.putconn(conn)
def _validate_column_name(cur, table, column):
cur.execute("SELECT get_column_names(%s) AS name", (table,))
if all([column!=col["name"] for col in cur.fetchall()]):
raise RuntimeError("Invalid column name")
def authorize(username, password, useragent):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_request(tfm_user_auth(%s, %s), %s) AS sid", (username, password, useragent))
sid = cur.fetchone()["sid"]
return TSession(sid)
class TSession:
sid = None
def __init__(self, sid):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_validate(%s) IS NOT NULL AS valid", (sid,))
if not cur.fetchone()["valid"]:
raise RuntimeError("Invalid sid")
self.sid = sid
def terminate(self):
with _db_cursor() as cur:
cur.execute("CALL tfm_session_terminate(%s)", (self.sid,))
del self
@property
def username(self):
with _db_cursor() as cur:
cur.execute("SELECT tfm_session_username(%s) AS name", (self.sid,))
return cur.fetchone()["name"]
@property
def is_admin(self):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_user_get_info(%s)", (self.sid,))
return cur.fetchone()["can_edit"]
def get_files(self, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_files_by_filter(self, philter=None, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_filter(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, philter))
return list(map(dict, cur.fetchall()))
def get_tags(self, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_tags(%%s) ORDER BY %s %s, name ASC OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_categories(self, order_key=DEFAULT_SORTING["categories"]["key"], order_asc=DEFAULT_SORTING["categories"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_categories", order_key)
cur.execute("SELECT * FROM tfm_get_categories(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_pools(self, order_key=DEFAULT_SORTING["pools"]["key"], order_asc=DEFAULT_SORTING["pools"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_pools", order_key)
cur.execute("SELECT * FROM tfm_get_pools(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_autotags(self, order_key="child_id", order_asc=True, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_autotags", order_key)
cur.execute("SELECT * FROM tfm_get_autotags(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_my_sessions(self, order_key="started", order_asc=False, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_sessions", order_key)
cur.execute("SELECT * FROM tfm_get_my_sessions(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid,))
return list(map(dict, cur.fetchall()))
def get_tags_by_file(self, file_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_tags_by_file(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, file_id))
return list(map(dict, cur.fetchall()))
def get_files_by_tag(self, tag_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_tag(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, tag_id))
return list(map(dict, cur.fetchall()))
def get_files_by_pool(self, pool_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_files_by_pool(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, pool_id))
return list(map(dict, cur.fetchall()))
def get_parent_tags(self, tag_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_tags", order_key)
cur.execute("SELECT * FROM tfm_get_parent_tags(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, tag_id))
return list(map(dict, cur.fetchall()))
def get_my_file_views(self, file_id=None, order_key="datetime", order_asc=False, offset=0, limit=None):
with _db_cursor() as cur:
_validate_column_name(cur, "v_files", order_key)
cur.execute("SELECT * FROM tfm_get_my_file_views(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
order_key,
"ASC" if order_asc else "DESC",
int(offset),
int(limit) if limit is not None else "ALL"
), (self.sid, file_id))
return list(map(dict, cur.fetchall()))
def get_file(self, file_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_files(%s) WHERE id=%s", (self.sid, file_id))
return cur.fetchone()
def get_tag(self, tag_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_tags(%s) WHERE id=%s", (self.sid, tag_id))
return cur.fetchone()
def get_category(self, category_id):
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_get_categories(%s) WHERE id=%s", (self.sid, category_id))
return cur.fetchone()
def view_file(self, file_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_view_file(%s, %s)", (self.sid, file_id))
def add_file(self, path, datetime=None, notes=None, is_private=None, orig_name=True):
if not isfile(path):
raise FileNotFoundError("No such file '%s'" % path)
if not access(conf["Paths"]["Files"], W_OK) or not access(conf["Paths"]["Thumbs"], W_OK):
raise PermissionError("Invalid directories for files and thumbs")
mime = mage.from_file(path)
if orig_name == True:
orig_name = basename(path)
with _db_cursor() as cur:
cur.execute("SELECT * FROM tfm_add_file(%s, %s, %s, %s, %s, %s)", (self.sid, mime, datetime, notes, is_private, orig_name))
res = cur.fetchone()
file_id = res["f_id"]
ext = res["ext"]
file_path = join(conf["Paths"]["Files"], file_id)
move(path, file_path)
thumb_path = previewer.get_jpeg_preview(file_path, height=160, width=160)
preview_path = previewer.get_jpeg_preview(file_path, height=1080, width=1920)
chmod(file_path, 0o664)
chmod(thumb_path, 0o664)
chmod(preview_path, 0o664)
return file_id, ext
def add_tag(self, name, notes=None, color=None, category_id=None, is_private=None):
if color is not None:
color = color.replace('#', '')
if not category_id:
category_id = None
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_tag(%s, %s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, category_id, is_private))
return cur.fetchone()["id"]
def add_category(self, name, notes=None, color=None, is_private=None):
if color is not None:
color = color.replace('#', '')
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_category(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, is_private))
return cur.fetchone()["id"]
def add_pool(self, name, notes=None, parent_id=None, is_private=None):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_pool(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, parent_id, is_private))
return cur.fetchone()["id"]
def add_autotag(self, child_id, parent_id, is_active=None, apply_to_existing=None):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_autotag(%s, %s, %s, %s, %s) AS added", (self.sid, child_id, parent_id, is_active, apply_to_existing))
return cur.fetchone()["added"]
def add_file_to_tag(self, file_id, tag_id):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_file_to_tag(%s, %s, %s) AS id", (self.sid, file_id, tag_id))
return list(map(lambda t: t["id"], cur.fetchall()))
def add_file_to_pool(self, file_id, pool_id):
with _db_cursor() as cur:
cur.execute("SELECT tfm_add_file_to_pool(%s, %s, %s) AS added", (self.sid, file_id, pool_id))
return cur.fetchone()["added"]
def edit_file(self, file_id, mime=None, datetime=None, notes=None, is_private=None):
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_file(%s, %s, %s, %s, %s, %s)", (self.sid, file_id, mime, datetime, notes, is_private))
def edit_tag(self, tag_id, name=None, notes=None, color=None, category_id=None, is_private=None):
if color is not None:
color = color.replace('#', '')
if not category_id:
category_id = None
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_tag(%s, %s, %s, %s, %s, %s, %s)", (self.sid, tag_id, name, notes, color, category_id, is_private))
def edit_category(self, category_id, name=None, notes=None, color=None, is_private=None):
if color is not None:
color = color.replace('#', '')
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_category(%s, %s, %s, %s, %s, %s)", (self.sid, category_id, name, notes, color, is_private))
def edit_pool(self, pool_id, name=None, notes=None, parent_id=None, is_private=None):
with _db_cursor() as cur:
cur.execute("CALL tfm_edit_pool(%s, %s, %s, %s, %s, %s)", (self.sid, pool_id, name, notes, parent_id, is_private))
def remove_file(self, file_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file(%s, %s)", (self.sid, file_id))
if system("rm %s/%s*" % (conf["Paths"]["Files"], file_id)):
raise RuntimeError("Failed to remove file '%s'" % file_id)
def remove_tag(self, tag_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_tag(%s, %s)", (self.sid, tag_id))
def remove_category(self, category_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_category(%s, %s)", (self.sid, category_id))
def remove_pool(self, pool_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_pool(%s, %s)", (self.sid, pool_id))
def remove_autotag(self, child_id, parent_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_autotag(%s, %s, %s)", (self.sid, child_id, parent_id))
def remove_file_to_tag(self, file_id, tag_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file_to_tag(%s, %s, %s)", (self.sid, file_id, tag_id))
def remove_file_to_pool(self, file_id, pool_id):
with _db_cursor() as cur:
cur.execute("CALL tfm_remove_file_to_pool(%s, %s, %s)", (self.sid, file_id, pool_id))
-22
View File
@@ -1,22 +0,0 @@
package main
import (
"tanabata/internal/storage/postgres"
)
func main() {
postgres.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
// data, statusCode, err := db.FileGetSlice(1, "", "+2", -2, 0)
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
// "name": "ponos.png",
// })
// statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
// v, e, err := postgres.FileGetAccess(1, "0197d15a-57f9-712c-991e-c512290e774f")
// fmt.Printf("V: %s, E: %s\n", v, e)
// fmt.Printf("Status: %d\n", statusCode)
// fmt.Printf("Error: %s\n", err)
// fmt.Printf("%+v\n", data)
}
@@ -1,22 +0,0 @@
package main
import (
"fmt"
"tanabata/db"
)
func main() {
db.InitDB("postgres://hiko:taikibansei@192.168.0.25/Tanabata_new?application_name=Tanabata%20testing")
// test_json := json.RawMessage([]byte("{\"valery\": \"ponosoff\"}"))
// data, statusCode, err := db.FileGetSlice(2, "", "+2", -2, 0)
// data, statusCode, err := db.FileGet(1, "0197d056-cfb0-76b5-97e0-bd588826393c")
// data, statusCode, err := db.FileAdd(1, "ABOBA.png", "image/png", time.Now(), "slkdfjsldkflsdkfj;sldkf", test_json)
// statusCode, err := db.FileUpdate(2, "0197d159-bf3a-7617-a3a8-a4a9fc39eca6", map[string]interface{}{
// "name": "ponos.png",
// })
statusCode, err := db.FileDelete(1, "0197d155-848f-7221-ba4a-4660f257c7d5")
fmt.Printf("Status: %d\n", statusCode)
fmt.Printf("Error: %s\n", err)
// fmt.Printf("%+v\n", data)
}
-79
View File
@@ -1,79 +0,0 @@
package db
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var connPool *pgxpool.Pool
func InitDB(connString string) error {
poolConfig, err := pgxpool.ParseConfig(connString)
if err != nil {
return fmt.Errorf("error while parsing connection string: %w", err)
}
poolConfig.MaxConns = 100
poolConfig.MinConns = 0
poolConfig.MaxConnLifetime = time.Hour
poolConfig.HealthCheckPeriod = 30 * time.Second
connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
return fmt.Errorf("error while initializing DB connections pool: %w", err)
}
return nil
}
func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
ctx := context.Background()
tx, err := connPool.Begin(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode, err = handler(ctx, tx)
if err != nil {
tx.Rollback(ctx)
return
}
err = tx.Commit(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
}
// Handle database error
func handleDBError(errIn error) (statusCode int, err error) {
if errIn == nil {
statusCode = http.StatusOK
return
}
if errors.Is(errIn, pgx.ErrNoRows) {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
var pgErr *pgconn.PgError
if errors.As(errIn, &pgErr) {
switch pgErr.Code {
case "22P02", "22007": // Invalid data format
err = fmt.Errorf("%s", pgErr.Message)
statusCode = http.StatusBadRequest
return
case "23505": // Unique constraint violation
err = fmt.Errorf("already exists")
statusCode = http.StatusConflict
return
}
}
return http.StatusInternalServerError, errIn
}
-53
View File
@@ -1,53 +0,0 @@
package db
import (
"fmt"
"net/http"
"strconv"
"strings"
)
// Convert "filter" URL param to SQL "WHERE" condition
func filterToSQL(filter string) (sql string, statusCode int, err error) {
// filterTokens := strings.Split(string(filter), ";")
sql = "(true)"
return
}
// Convert "sort" URL param to SQL "ORDER BY"
func sortToSQL(sort string) (sql string, statusCode int, err error) {
if sort == "" {
return
}
sortOptions := strings.Split(sort, ",")
sql = " ORDER BY "
for i, sortOption := range sortOptions {
sortOrder := sortOption[:1]
sortColumn := sortOption[1:]
// parse sorting order marker
switch sortOrder {
case "+":
sortOrder = "ASC"
case "-":
sortOrder = "DESC"
default:
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
statusCode = http.StatusBadRequest
return
}
// validate sorting column
var n int
n, err = strconv.Atoi(sortColumn)
if err != nil || n < 0 {
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
statusCode = http.StatusBadRequest
return
}
// add sorting option to query
if i > 0 {
sql += ","
}
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
}
return
}
-19
View File
@@ -1,19 +0,0 @@
module tanabata
go 1.23.0
toolchain go1.23.10
require github.com/jackc/pgx/v5 v5.7.5
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx v3.6.2+incompatible // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.9.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
-32
View File
@@ -1,32 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,122 +0,0 @@
package domain
import (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
CanCreate bool `json:"canCreate"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type (
CategoryCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
CategoryItem struct {
CategoryCore
}
CategoryFull struct {
CategoryCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
}
)
type (
FileCore struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
}
FileItem struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
}
FileFull struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
Tags []TagCore `json:"tags"`
Viewed int `json:"viewed"`
}
)
type (
TagCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
TagItem struct {
TagCore
Category CategoryCore `json:"category"`
}
TagFull struct {
TagCore
Category CategoryCore `json:"category"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
UsedIncl int `json:"usedIncl"`
UsedExcl int `json:"usedExcl"`
}
)
type Autotag struct {
TriggerTag TagCore `json:"triggerTag"`
AddTag TagCore `json:"addTag"`
IsActive bool `json:"isActive"`
}
type (
PoolCore struct {
ID string `json:"id"`
Name string `json:"name"`
}
PoolItem struct {
PoolCore
}
PoolFull struct {
PoolCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Viewed int `json:"viewed"`
}
)
type Session struct {
ID int `json:"id"`
UserAgent string `json:"userAgent"`
StartedAt time.Time `json:"startedAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastActivity time.Time `json:"lastActivity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
@@ -1,16 +0,0 @@
package postgres
import "context"
func UserLogin(ctx context.Context, name, password string) (user_id int, err error) {
row := connPool.QueryRow(ctx, "SELECT id FROM users WHERE name=$1 AND password=crypt($2, password)", name, password)
err = row.Scan(&user_id)
return
}
func UserAuth(ctx context.Context, user_id int) (ok, isAdmin bool) {
row := connPool.QueryRow(ctx, "SELECT is_admin FROM users WHERE id=$1", user_id)
err := row.Scan(&isAdmin)
ok = (err == nil)
return
}
@@ -1,268 +0,0 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/internal/domain"
)
type FileStore struct {
db *pgxpool.Pool
}
func NewFileStore(db *pgxpool.Pool) *FileStore {
return &FileStore{db: db}
}
// Get user's access rights to file
func (s *FileStore) getAccess(user_id int, file_id string) (canView, canEdit bool, err error) {
ctx := context.Background()
row := connPool.QueryRow(ctx, `
SELECT
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
FROM data.files f
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
LEFT JOIN system.users u ON u.id=$1
WHERE f.id=$2
`, user_id, file_id)
err = row.Scan(&canView, &canEdit)
return
}
// Get a set of files
func (s *FileStore) GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error) {
filterCond, statusCode, err := filterToSQL(filter)
if err != nil {
return
}
sortExpr, statusCode, err := sortToSQL(sort)
if err != nil {
return
}
// prepare query
query := `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
`
query += filterCond
queryCount := query
query += sortExpr
if limit >= 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
// execute query
statusCode, err = transaction(func(ctx context.Context, tx pgx.Tx) (statusCode int, err error) {
rows, err := tx.Query(ctx, query, user_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
defer rows.Close()
count := 0
for rows.Next() {
var file domain.FileItem
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
files.Data = append(files.Data, file)
count++
}
err = rows.Err()
if err != nil {
statusCode = http.StatusInternalServerError
return
}
files.Pagination.Limit = limit
files.Pagination.Offset = offset
files.Pagination.Count = count
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
err = row.Scan(&files.Pagination.Total)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
})
if err == nil {
statusCode = http.StatusOK
}
return
}
// Get file
func (s *FileStore) Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error) {
ctx := context.Background()
row := connPool.QueryRow(ctx, `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin,
f.notes,
f.metadata,
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE NOT f.is_deleted AND f.id=$2 AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
`, user_id, file_id)
err = row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
if err != nil {
statusCode, err = handleDBError(err)
return
}
rows, err := connPool.Query(ctx, `
SELECT
t.id,
t.name,
COALESCE(t.color, c.color)
FROM data.tags t
LEFT JOIN data.categories c ON c.id=t.category_id
JOIN data.file_tag ft ON ft.tag_id=t.id
WHERE ft.file_id=$1
`, file_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
defer rows.Close()
for rows.Next() {
var tag domain.TagCore
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
file.Tags = append(file.Tags, tag)
}
err = rows.Err()
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode = http.StatusOK
return
}
// Add file
func (s *FileStore) Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error) {
ctx := context.Background()
var mime_id int
var extension string
row := connPool.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
err = row.Scan(&mime_id, &extension)
if err != nil {
if err == pgx.ErrNoRows {
err = fmt.Errorf("unsupported file type: %q", mime)
statusCode = http.StatusBadRequest
} else {
statusCode, err = handleDBError(err)
}
return
}
row = connPool.QueryRow(ctx, `
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
RETURNING id
`, name, mime_id, datetime, user_id, notes, metadata)
err = row.Scan(&file.ID)
if err != nil {
statusCode, err = handleDBError(err)
return
}
file.Name.String = name
file.Name.Valid = (name != "")
file.MIME.Name = mime
file.MIME.Extension = extension
statusCode = http.StatusOK
return
}
// Update file
func (s *FileStore) Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error) {
if len(updates) == 0 {
err = fmt.Errorf("no fields provided for update")
statusCode = http.StatusBadRequest
return
}
writableFields := map[string]bool{
"name": true,
"datetime": true,
"notes": true,
"metadata": true,
}
query := "UPDATE data.files SET"
newValues := []interface{}{user_id}
count := 2
for field, value := range updates {
if !writableFields[field] {
err = fmt.Errorf("invalid field: %q", field)
statusCode = http.StatusBadRequest
return
}
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
newValues = append(newValues, value)
count++
}
query += fmt.Sprintf(
" WHERE id=$%d AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$%d AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
count, count)
newValues = append(newValues, file_id)
ctx := context.Background()
commandTag, err := connPool.Exec(ctx, query, newValues...)
if err != nil {
statusCode, err = handleDBError(err)
return
}
if commandTag.RowsAffected() == 0 {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
statusCode = http.StatusNoContent
return
}
// Delete file
func (s *FileStore) Delete(user_id int, file_id string) (statusCode int, err error) {
ctx := context.Background()
commandTag, err := connPool.Exec(ctx,
"DELETE FROM data.files WHERE id=$2 AND (creator_id=$1 OR (SELECT edit FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))",
user_id, file_id)
if err != nil {
statusCode, err = handleDBError(err)
return
}
if commandTag.RowsAffected() == 0 {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
statusCode = http.StatusNoContent
return
}
@@ -1,92 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Storage struct {
db *pgxpool.Pool
}
var connPool *pgxpool.Pool
// Initialize new database storage
func New(dbURL string) (*Storage, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(dbURL)
if err != nil {
return nil, fmt.Errorf("failed to parse DB URL: %w", err)
}
config.MaxConns = 10
config.MinConns = 2
config.HealthCheckPeriod = time.Minute
db, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
err = db.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("database ping failed: %w", err)
}
return &Storage{db: db}, nil
}
// Close database storage
func (s *Storage) Close() {
s.db.Close()
}
// Run handler inside transaction
func (s *Storage) transaction(ctx context.Context, handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
tx, err := connPool.Begin(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode, err = handler(ctx, tx)
if err != nil {
tx.Rollback(ctx)
return
}
err = tx.Commit(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
}
// Handle database error
func (s *Storage) handleDBError(errIn error) (statusCode int, err error) {
if errIn == nil {
statusCode = http.StatusOK
return
}
if errors.Is(errIn, pgx.ErrNoRows) {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
var pgErr *pgconn.PgError
if errors.As(errIn, &pgErr) {
switch pgErr.Code {
case "22P02", "22007": // Invalid data format
err = fmt.Errorf("%s", pgErr.Message)
statusCode = http.StatusBadRequest
return
case "23505": // Unique constraint violation
err = fmt.Errorf("already exists")
statusCode = http.StatusConflict
return
}
}
return http.StatusInternalServerError, errIn
}
@@ -1,53 +0,0 @@
package postgres
import (
"fmt"
"net/http"
"strconv"
"strings"
)
// Convert "filter" URL param to SQL "WHERE" condition
func filterToSQL(filter string) (sql string, statusCode int, err error) {
// filterTokens := strings.Split(string(filter), ";")
sql = "(true)"
return
}
// Convert "sort" URL param to SQL "ORDER BY"
func sortToSQL(sort string) (sql string, statusCode int, err error) {
if sort == "" {
return
}
sortOptions := strings.Split(sort, ",")
sql = " ORDER BY "
for i, sortOption := range sortOptions {
sortOrder := sortOption[:1]
sortColumn := sortOption[1:]
// parse sorting order marker
switch sortOrder {
case "+":
sortOrder = "ASC"
case "-":
sortOrder = "DESC"
default:
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
statusCode = http.StatusBadRequest
return
}
// validate sorting column
var n int
n, err = strconv.Atoi(sortColumn)
if err != nil || n < 0 {
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
statusCode = http.StatusBadRequest
return
}
// add sorting option to query
if i > 0 {
sql += ","
}
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
}
return
}
@@ -1,21 +0,0 @@
package storage
import (
"encoding/json"
"time"
"tanabata/internal/domain"
)
type Storage interface {
FileRepository
Close()
}
type FileRepository interface {
GetSlice(user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], statusCode int, err error)
Get(user_id int, file_id string) (file domain.FileFull, statusCode int, err error)
Add(user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, statusCode int, err error)
Update(user_id int, file_id string, updates map[string]interface{}) (statusCode int, err error)
Delete(user_id int, file_id string) (statusCode int, err error)
}
-120
View File
@@ -1,120 +0,0 @@
package models
import (
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
CanCreate bool `json:"canCreate"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type (
CategoryCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
CategoryItem struct {
CategoryCore
}
CategoryFull struct {
CategoryCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
}
)
type (
FileCore struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
}
FileItem struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
}
FileFull struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
Tags []TagCore `json:"tags"`
Viewed int `json:"viewed"`
}
)
type (
TagCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
}
TagItem struct {
TagCore
Category CategoryCore `json:"category"`
}
TagFull struct {
TagCore
Category CategoryCore `json:"category"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
}
)
type Autotag struct {
TriggerTag TagCore `json:"triggerTag"`
AddTag TagCore `json:"addTag"`
IsActive bool `json:"isActive"`
}
type (
PoolCore struct {
ID string `json:"id"`
Name string `json:"name"`
}
PoolItem struct {
PoolCore
}
PoolFull struct {
PoolCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes pgtype.Text `json:"notes"`
Viewed int `json:"viewed"`
}
)
type Session struct {
ID int `json:"id"`
UserAgent string `json:"userAgent"`
StartedAt time.Time `json:"startedAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastActivity time.Time `json:"lastActivity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
-36
View File
@@ -1,36 +0,0 @@
body {
justify-content: center;
align-items: center;
}
.decoration {
position: absolute;
top: 0;
}
.decoration.left {
left: 0;
width: 20vw;
}
.decoration.right {
right: 0;
width: 20vw;
}
#auth {
max-width: 100%;
}
#auth h1 {
margin-bottom: 28px;
}
#auth .form-control {
margin: 14px 0;
border-radius: 14px;
}
#login {
margin-top: 20px;
}
-54
View File
@@ -1,54 +0,0 @@
html, body {
margin: 0;
padding: 0;
}
body {
background-color: #312F45;
color: #f0f0f0;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
font-family: Epilogue;
overflow-x: hidden;
}
.btn {
height: 50px;
width: 100%;
border-radius: 14px;
font-size: 1.5rem;
font-weight: 500;
}
.btn-primary {
background-color: #9592B5;
border-color: #454261;
}
.btn-primary:hover {
background-color: #7D7AA4;
border-color: #454261;
}
.btn-danger {
background-color: #DB6060;
border-color: #851E1E;
}
.btn-danger:hover {
background-color: #D64848;
border-color: #851E1E;
}
.btn-row {
display: flex;
justify-content: space-between;
}
-388
View File
@@ -1,388 +0,0 @@
header, footer {
margin: 0;
padding: 10px;
box-sizing: border-box;
}
header {
padding: 20px;
box-shadow: 0 5px 5px #0004;
}
.icon-header {
height: .8em;
}
#select {
cursor: pointer;
}
.sorting {
position: relative;
display: flex;
justify-content: space-between;
}
#sorting {
cursor: pointer;
}
.highlighted {
color: #9999AD;
}
#icon-expand {
height: 10px;
}
#sorting-options {
position: absolute;
right: 0;
top: 114%;
padding: 4px 10px;
box-sizing: border-box;
background-color: #111118;
border-radius: 10px;
text-align: left;
box-shadow: 0 0 10px black;
z-index: 9999;
}
.sorting-option {
padding: 4px 0;
display: flex;
justify-content: space-between;
}
.sorting-option input[type="radio"] {
float: unset;
margin-left: 1.8em;
}
.filtering-wrapper {
margin-top: 10px;
}
.filtering-block {
position: absolute;
top: 128px;
left: 14px;
right: 14px;
padding: 14px;
background-color: #111118;
border-radius: 10px;
box-shadow: 0 0 10px 4px #0004;
z-index: 9998;
}
main {
margin: 0;
padding: 10px;
height: 100%;
display: flex;
justify-content: space-between;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: scroll;
}
main:after {
content: "";
flex: auto;
}
.item-preview {
position: relative;
}
.item-selected:after {
content: "";
display: block;
position: absolute;
top: 10px;
right: 10px;
width: 100%;
height: 50%;
background-image: url("/static/images/icon-select.svg");
background-size: contain;
background-position: right;
background-repeat: no-repeat;
}
.file-preview {
margin: 1px 0;
padding: 0;
width: 160px;
height: 160px;
max-width: calc(33vw - 7px);
max-height: calc(33vw - 7px);
overflow: hidden;
cursor: pointer;
}
.file-thumb {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.file-preview .overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: #0002;
}
.file-preview:hover .overlay {
background-color: #0004;
}
.tag-preview, .filtering-token {
margin: 5px 5px;
padding: 5px 10px;
border-radius: 5px;
background-color: #444455;
cursor: pointer;
}
.category-preview {
margin: 5px 5px;
padding: 5px 10px;
border-radius: 5px;
background-color: #444455;
cursor: pointer;
}
.file {
margin: 0;
padding: 0;
min-width: 100vw;
min-height: 100vh;
max-width: 100vw;
max-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: black;
}
.file .preview-img {
max-width: 100vw;
max-height: 100vh;
}
.selection-manager {
position: fixed;
left: 10px;
right: 10px;
bottom: 65px;
box-sizing: border-box;
max-height: 40vh;
display: flex;
flex-direction: column;
padding: 15px 10px;
background-color: #181721;
border-radius: 10px;
box-shadow: 0 0 5px #0008;
}
.selection-manager hr {
margin: 5px 0;
}
.selection-header {
display: flex;
justify-content: space-between;
}
.selection-header > * {
cursor: pointer;
}
#selection-edit-tags {
color: #4DC7ED;
}
#selection-add-to-pool {
color: #F5E872;
}
#selection-delete {
color: #DB6060;
}
.selection-tags {
max-height: 100%;
overflow-x: hidden;
overflow-y: scroll;
}
input[type="color"] {
width: 100%;
}
.tags-container, .filtering-operators, .filtering-tokens {
padding: 5px;
background-color: #212529;
border: 1px solid #495057;
border-radius: .375rem;
display: flex;
justify-content: space-between;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
box-sizing: border-box;
}
.filtering-operators {
margin-bottom: 15px;
}
.tags-container, .filtering-tokens {
margin: 15px 0;
height: 200px;
overflow-x: hidden;
overflow-y: scroll;
}
.tags-container:after, .filtering-tokens:after {
content: "";
flex: auto;
}
.tags-container-selected {
height: 100px;
}
#files-filter {
margin-bottom: 0;
height: 56px;
}
.viewer-wrapper {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
/* overflow-y: scroll;*/
}
.viewer-nav {
position: absolute;
top: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.viewer-nav:hover {
background-color: #b4adff40;
}
.viewer-nav-prev {
left: 0;
right: 80vw;
}
.viewer-nav-next {
left: 80vw;
right: 0;
}
.viewer-nav-close {
left: 0;
right: 0;
bottom: unset;
height: 15vh;
}
.viewer-nav-icon {
width: 20px;
height: 32px;
}
.viewer-nav-close > .viewer-nav-icon {
width: 16px;
height: 16px;
}
#viewer {
width: 100%;
height: 100%;
max-width: 100%;
}
.sessions-wrapper {
padding: 14px;
background-color: #111118;
border-radius: 10px;
}
.btn-terminate {
height: 20px;
cursor: pointer;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #0007;
}
.nav {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 18vw;
background: transparent;
border: 0;
border-radius: 10px;
outline: 0;
}
.nav.curr, .nav:hover {
background-color: #343249;
}
.navicon {
display: block;
height: 30px;
}
#loader {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #000a;
z-index: 9999;
}
.loader-wrapper {
padding: 15px;
border-radius: 12px;
background-color: white;
}
.loader-img {
max-width: 20vw;
max-height: 20vh;
}
@@ -1,4 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.51245 10.9993C4.51245 10.7415 4.61483 10.4944 4.79705 10.3122C4.97928 10.1299 5.22644 10.0276 5.48415 10.0276H10.0097V5.50203C10.0097 5.24432 10.112 4.99717 10.2943 4.81494C10.4765 4.63271 10.7237 4.53033 10.9814 4.53033C11.2391 4.53033 11.4862 4.63271 11.6685 4.81494C11.8507 4.99717 11.9531 5.24432 11.9531 5.50203V10.0276H16.4786C16.7363 10.0276 16.9835 10.1299 17.1657 10.3122C17.3479 10.4944 17.4503 10.7415 17.4503 10.9993C17.4503 11.257 17.3479 11.5041 17.1657 11.6863C16.9835 11.8686 16.7363 11.971 16.4786 11.971H11.9531V16.4965C11.9531 16.7542 11.8507 17.0013 11.6685 17.1836C11.4862 17.3658 11.2391 17.4682 10.9814 17.4682C10.7237 17.4682 10.4765 17.3658 10.2943 17.1836C10.112 17.0013 10.0097 16.7542 10.0097 16.4965V11.971H5.48415C5.22644 11.971 4.97928 11.8686 4.79705 11.6863C4.61483 11.5041 4.51245 11.257 4.51245 10.9993Z" fill="#9999AD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.91409 0.335277C8.9466 -0.111759 13.0162 -0.111759 17.0487 0.335277C19.4157 0.599579 21.3267 2.46394 21.604 4.84396C22.0834 8.93416 22.0834 13.0658 21.604 17.156C21.3254 19.536 19.4144 21.3991 17.0487 21.6647C13.0162 22.1118 8.9466 22.1118 4.91409 21.6647C2.54704 21.3991 0.636031 19.536 0.358773 17.156C-0.119591 13.0659 -0.119591 8.93404 0.358773 4.84396C0.636031 2.46394 2.54833 0.599579 4.91409 0.335277ZM16.8336 2.26572C12.944 1.83459 9.01873 1.83459 5.12916 2.26572C4.40913 2.3456 3.73705 2.66589 3.2215 3.17486C2.70595 3.68383 2.37704 4.35174 2.28792 5.07069C1.82714 9.01056 1.82714 12.9907 2.28792 16.9306C2.37732 17.6493 2.70634 18.3169 3.22186 18.8256C3.73739 19.3343 4.40932 19.6544 5.12916 19.7343C8.98616 20.1644 12.9766 20.1644 16.8336 19.7343C17.5532 19.6542 18.2248 19.3339 18.7401 18.8253C19.2554 18.3166 19.5842 17.6491 19.6735 16.9306C20.1343 12.9907 20.1343 9.01056 19.6735 5.07069C19.5839 4.3524 19.255 3.68524 18.7397 3.17681C18.2245 2.66839 17.553 2.34835 16.8336 2.26831" fill="#9999AD"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,3 +0,0 @@
<svg width="16" height="9" viewBox="0 0 16 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.10279 7.27726L14.8104 0.579294C14.8755 0.513843 14.9531 0.462123 15.0386 0.427199C15.1241 0.392275 15.2157 0.374857 15.308 0.375976C15.4003 0.377095 15.4915 0.396729 15.5761 0.433714C15.6607 0.470699 15.737 0.524284 15.8005 0.591294C15.9306 0.728358 16.0022 0.910756 15.9999 1.09973C15.9977 1.2887 15.9219 1.46935 15.7885 1.60329L8.58484 8.79625C8.5202 8.86132 8.44324 8.91285 8.35845 8.94783C8.27366 8.98282 8.18275 9.00055 8.09103 8.99999C7.99931 8.99943 7.90862 8.98059 7.82427 8.94458C7.73991 8.90857 7.66359 8.8561 7.59975 8.79025L0.204043 1.21929C0.0731536 1.08376 0 0.902704 0 0.714294C0 0.525883 0.0731536 0.344832 0.204043 0.209296C0.268362 0.143072 0.345316 0.0904251 0.43035 0.0544745C0.515384 0.0185239 0.606768 0 0.69909 0C0.791413 0 0.882797 0.0185239 0.967831 0.0544745C1.05286 0.0904251 1.12982 0.143072 1.19414 0.209296L8.10279 7.27726Z" fill="#9999AD"/>
</svg>

Before

Width:  |  Height:  |  Size: 985 B

@@ -1,4 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z" fill="#9999AD"/>
<path d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325" stroke="#9999AD" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,3 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z" fill="#9999AD"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,3 +0,0 @@
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.414 7.97C1.78906 7.59506 2.29767 7.38443 2.828 7.38443C3.35833 7.38443 3.86695 7.59506 4.242 7.97L11.314 15.042L25.454 0.900004C25.6397 0.714184 25.8602 0.566757 26.1028 0.466141C26.3455 0.365526 26.6056 0.313692 26.8683 0.313599C27.131 0.313506 27.3911 0.365156 27.6339 0.4656C27.8766 0.566044 28.0972 0.713315 28.283 0.899004C28.4688 1.08469 28.6163 1.30516 28.7169 1.54783C28.8175 1.79049 28.8693 2.0506 28.8694 2.3133C28.8695 2.57599 28.8179 2.83614 28.7174 3.07887C28.617 3.32161 28.4697 3.54218 28.284 3.728L11.314 20.698L1.414 10.798C1.03906 10.4229 0.82843 9.91433 0.82843 9.384C0.82843 8.85368 1.03906 8.34506 1.414 7.97Z" fill="white" stroke="black" stroke-width="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 794 B

@@ -1,4 +0,0 @@
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z" stroke="#9999AD" stroke-width="1.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z" stroke="#9999AD" stroke-width="1.5"/>
<path d="M11.4962 19.1504L19.1731 11.4724" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,3 +0,0 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 25C5.59625 25 0 19.4037 0 12.5C0 5.59625 5.59625 0 12.5 0C19.4037 0 25 5.59625 25 12.5C25 19.4037 19.4037 25 12.5 25ZM12.5 22.5C15.1522 22.5 17.6957 21.4464 19.5711 19.5711C21.4464 17.6957 22.5 15.1522 22.5 12.5C22.5 9.84783 21.4464 7.3043 19.5711 5.42893C17.6957 3.55357 15.1522 2.5 12.5 2.5C9.84783 2.5 7.3043 3.55357 5.42893 5.42893C3.55357 7.3043 2.5 9.84783 2.5 12.5C2.5 15.1522 3.55357 17.6957 5.42893 19.5711C7.3043 21.4464 9.84783 22.5 12.5 22.5ZM6.25 11.25H18.75V13.75H6.25V11.25Z" fill="#DB6060"/>
</svg>

Before

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

@@ -1,22 +0,0 @@
$(document).on("submit", "#object-add", function (e) {
e.preventDefault();
$("#loader").css("display", "");
$.ajax({
url: location.pathname,
type: "POST",
data: $(this).serialize(),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
-22
View File
@@ -1,22 +0,0 @@
$(document).on("submit", "#object-add", function (e) {
e.preventDefault();
$("#loader").css("display", "");
$.ajax({
url: location.pathname,
type: "POST",
data: $(this).serialize(),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
-19
View File
@@ -1,19 +0,0 @@
$("#auth").on("submit", function submit(e) {
e.preventDefault();
$.ajax({
url: "/auth",
type: "POST",
data: $("#auth").serialize(),
dataType: "json",
success: function(resp) {
if (resp.status) {
location.reload();
} else {
alert(resp.error);
}
},
failure: function(err) {
alert(err);
}
});
});
-20
View File
@@ -1,20 +0,0 @@
$(document).on("submit", "#object-edit", function (e) {
e.preventDefault();
$("#loader").css("display", "");
$.ajax({
url: location.pathname + "/edit",
type: "POST",
data: $(this).serialize(),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (!resp.status) {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
-88
View File
@@ -1,88 +0,0 @@
$(document).on("input", "#file-tags-filter", function (e) {
let filter = $(this).val().toLowerCase();
let unfiltered = $("#file-tags-other > .tag-preview");
if (filter === "") {
unfiltered.css("display", "");
return;
}
unfiltered.each((index, element) => {
let current = $(element);
if (current.text().toLowerCase().includes(filter)) {
current.css("display", "");
} else {
current.css("display", "none");
}
});
});
$(document).on("click", "#file-tags-other > .tag-preview", function (e) {
$("#loader").css("display", "");
$.ajax({
url: location.pathname + "/tag",
type: "POST",
contentType: "application/json",
data: JSON.stringify({add: true, tag_id: $(this).attr("tag_id")}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
resp.tags.forEach((tag_id) => {
$(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
});
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("click", "#file-tags-selected > .tag-preview", function (e) {
$("#loader").css("display", "");
let tag_id = $(this).attr("tag_id");
$.ajax({
url: location.pathname + "/tag",
type: "POST",
contentType: "application/json",
data: JSON.stringify({add: false, tag_id: $(this).attr("tag_id")}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
$(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("submit", "#object-edit", function (e) {
e.preventDefault();
$("#loader").css("display", "");
$.ajax({
url: location.pathname + "/edit",
type: "POST",
data: $(this).serialize(),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (!resp.status) {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
-130
View File
@@ -1,130 +0,0 @@
var curr_page = 0;
var load_lock = false;
var init_filter = null;
function files_load() {
if (load_lock) {
return;
}
load_lock = true;
let container = $("main");
$("#loader").css("display", "");
$.ajax({
url: "/api/files?limit=50&offset=" + curr_page*50 + (init_filter ? "&filter=" + encodeURIComponent("{" + init_filter + "}") : ""),
type: "GET",
contentType: "application/json",
async: false,
success: function (resp) {
$("#loader").css("display", "none");
resp.forEach((file) => {
container.append(`<div class="item-preview file-preview" file_id="${file.id}"><img src="/static/thumbs/${file.id}" alt="" class="file-thumb"><div class="overlay"></div></div>`);
});
if (resp.length == 50) {
load_lock = false;
}
},
error: function (xhr, status) {
$("#loader").css("display", "none");
alert(xhr.responseText);
location.href = "/files";
}
});
curr_page++;
}
function tags_load(target) {
$("#loader").css("display", "");
$.ajax({
url: "/api/tags",
type: "GET",
contentType: "application/json",
async: false,
success: function (resp) {
$("#loader").css("display", "none");
resp.forEach((tag) => {
target.append(`<div class="filtering-token" val="t=${tag.id}" style="background-color: #${tag.category_color}">${escapeHTML(tag.name)}</div>`);
});
},
error: function (xhr, status) {
$("#loader").css("display", "none");
alert(status);
}
});
}
function filter_load() {
if (!init_filter) {
return;
}
$("#files-filter").html("");
let filtering_tokens = init_filter.split(',');
filtering_tokens.forEach((element) => {
$(`.filtering-block .filtering-token[val='${element}']`).clone().appendTo("#files-filter");
});
}
$(window).on("load", function (e) {
init_filter = /filter=\{([^\}]+)/.exec(decodeURIComponent(location.search));
init_filter = init_filter ? init_filter[1] : null;
let container = $("main");
while (!load_lock && container.scrollTop() + container.innerHeight() >= container[0].scrollHeight) {
files_load();
}
tags_load($("#filtering-tokens-all"));
filter_load();
});
$("main").scroll(function (e) {
if ($(this).scrollTop() + $(this).innerHeight() >= $(this)[0].scrollHeight - 100) {
files_load();
}
});
$(document).on("click", "#files-filter", function (e) {
if ($(".filtering-block").is(":hidden")) {
$(".filtering-block").slideDown("fast");
}
});
$(document).on("click", "#filtering-apply", function (e) {
let filtering_tokens = [];
$("#files-filter > .filtering-token").each((index, element) => {
filtering_tokens.push($(element).attr("val"));
});
location.href = "/files?filter=" + encodeURIComponent("{" + filtering_tokens.join(',') + "}");
});
$(document).on("click", "#filtering-reset", function (e) {
$(".filtering-block").slideUp("fast");
filter_load();
$("#filter-filtering").val("").trigger("input");
});
$(document).on("click", ".filtering-block .filtering-token", function (e) {
$(this).clone().appendTo("#files-filter");
});
$(document).on("click", "#files-filter > .filtering-token", function (e) {
$(this).remove();
});
$(document).on("input", "#filter-filtering", function (e) {
let filter = $(this).val().toLowerCase();
let unfiltered = $("#filtering-tokens-all > .filtering-token");
if (filter === "") {
unfiltered.css("display", "");
return;
}
unfiltered.each((index, element) => {
let current = $(element);
if (current.text().toLowerCase().includes(filter)) {
current.css("display", "");
} else {
current.css("display", "none");
}
});
});
$(document).on("click", "#filter-filtering", function (e) {
$(this).val("").trigger("input");
});
-363
View File
@@ -1,363 +0,0 @@
var lazy_loader;
function escapeHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function beautify_date(date_string) {
if (date_string == null) {
return null;
}
let ts = new Date(date_string).getTime();
let tz = new Date().getTimezoneOffset();
return new Date(ts-tz*60000).toISOString().slice(0, 19).replace("T", " ");
}
function close_select_manager() {
$(".item-selected").removeClass("item-selected");
$(".selection-manager").css("display", "none");
$("#selection-count").text(0);
$("main").css("padding-bottom", "");
$("#selection-tags-other > .tag-preview").css("display", "");
$("#selection-tags-selected > .tag-preview").css("display", "none");
$("#selection-tags-filter").val("").trigger("input");
$(".selection-tags").css("display", "none");
}
function refresh_selection_tags() {
$("#loader").css("display", "");
let file_id_list = [];
$("main > .file-preview.item-selected").each((index, element) => {
file_id_list.push($(element).attr("file_id"));
});
$("#selection-tags-other > .tag-preview").css("display", "");
$("#selection-tags-selected > .tag-preview").css("display", "none");
$.ajax({
url: location.pathname + "/tags",
type: "POST",
contentType: "application/json",
data: JSON.stringify({action: "get", file_id_list: file_id_list}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
resp.tag_id_list.forEach((tag_id) => {
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
});
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
}
function select_handler(curr) {
let selection_count = +$("#selection-count").text();
if (curr.hasClass("item-selected")) {
curr.removeClass("item-selected");
selection_count--;
$("#selection-count").text(selection_count);
if (!selection_count) {
close_select_manager();
return;
}
} else {
curr.addClass("item-selected");
$(".selection-manager").css("display", "");
$("main").css("padding-bottom", "80px");
selection_count++;
$("#selection-count").text(selection_count);
}
refresh_selection_tags();
}
// $(window).on("load", function () {
// lazy_loader = $(".file-thumb").Lazy({
// scrollDirection: "vertical",
// effect: "fadeIn",
// visibleOnly: true,
// appendScroll: $("main")[0],
// chainable: false,
// });
// });
$(document).keyup(function (e) {
switch (e.key) {
case "Esc":
case "Escape":
close_select_manager();
break;
// case "Left":
// case "ArrowLeft":
// if (current_sasa_index >= 0) {
// file_prev();
// }
// break;
// case "Right":
// case "ArrowRight":
// if (current_sasa_index >= 0) {
// file_next();
// }
// break;
default:
return;
}
});
$(document).on("selectstart", ".item-preview", function (e) {
e.preventDefault();
});
$(document).on("click", "#select", function (e) {
if ($(".selection-manager").is(":visible")) {
close_select_manager();
return;
}
$(".selection-manager").css("display", "");
$("main").css("padding-bottom", "80px");
selection_count++;
$("#selection-count").text(selection_count);
});
$(document).on("click", "main > .file-preview", function (e) {
e.preventDefault();
if ($(".selection-manager").is(":visible")) {
select_handler($(this));
return;
}
let id = $(this).attr("file_id");
$("#viewer").attr("src", "/files/" + id);
$("#view-prev").attr("file_id", $(this).prev(":visible").attr("file_id"));
$("#view-next").attr("file_id", $(this).next(":visible").attr("file_id"));
$(".viewer-wrapper").css("display", "");
});
$(document).on("click", "main > .tag-preview", function (e) {
e.preventDefault();
if ($(".selection-manager").is(":visible")) {
select_handler($(this));
return;
}
let id = $(this).attr("tag_id");
location.href = "/tags/" + id;
});
$(document).on("click", "main > .category-preview", function (e) {
e.preventDefault();
if ($(".selection-manager").is(":visible")) {
select_handler($(this));
return;
}
let id = $(this).attr("category_id");
location.href = "/categories/" + id;
});
$(document).on("click", "#sorting", function (e) {
$("#sorting-options").slideToggle("fast");
if ($("#sorting-options").is(":visible")) {
key_prev = $("input[name='sorting'][prev-checked]").val();
key_curr = $("input[name='sorting']:checked").val();
asc_prev = $("input[name='order'][prev-checked]").val();
asc_curr = $("input[name='order']:checked").val();
if (key_curr != key_prev || asc_curr != asc_prev) {
$.ajax({
url: "/settings/sorting",
type: "POST",
contentType: "application/json",
data: JSON.stringify({[location.pathname.split('/')[1]]: {key: key_curr, asc: (asc_curr == "asc")}}),
dataType: "json",
success: function (resp) {
if (resp.status) {
location.reload();
} else {
alert(resp.error);
}
},
failure: function (err) {
alert(err);
}
});
}
}
});
$(document).on("input", "#filter", function (e) {
let filter = $(this).val().toLowerCase();
let unfiltered = $("main > .item-preview");
if (filter === "") {
unfiltered.css("display", "");
return;
}
unfiltered.each((index, element) => {
let current = $(element);
if (current.text().toLowerCase().includes(filter)) {
current.css("display", "");
} else {
current.css("display", "none");
}
});
});
$(document).on("click", ".filtering", function (e) {
$(this).val("").trigger("input");
})
$(document).on("click", "#selection-info", function (e) {
close_select_manager();
});
$(document).on("click", "#selection-edit-tags", function (e) {
$(".selection-tags").slideToggle("fast");
});
$(document).on("click", "#selection-delete", function (e) {
if (!confirm("Delete selected?")) {
return;
}
let file_id_list = [];
$("main > .file-preview.item-selected").each((index, element) => {
file_id_list.push($(element).attr("file_id"));
});
$.ajax({
url: location.pathname + "/delete",
type: "POST",
contentType: "application/json",
data: JSON.stringify({file_id_list: file_id_list}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
location.reload();
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("click", "#selection-tags-other > .tag-preview", function (e) {
$("#loader").css("display", "");
let tag_id = $(this).attr("tag_id");
let file_id_list = [];
$("main > .file-preview.item-selected").each((index, element) => {
file_id_list.push($(element).attr("file_id"));
});
$.ajax({
url: location.pathname + "/tags",
type: "POST",
contentType: "application/json",
data: JSON.stringify({action: "add", file_id_list: file_id_list, tag_id: tag_id}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
resp.tag_id_list.forEach((tag_id) => {
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
});
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("click", "#selection-tags-selected > .tag-preview", function (e) {
$("#loader").css("display", "");
let tag_id = $(this).attr("tag_id");
let file_id_list = [];
$("main > .file-preview.item-selected").each((index, element) => {
file_id_list.push($(element).attr("file_id"));
});
$.ajax({
url: location.pathname + "/tags",
type: "POST",
contentType: "application/json",
data: JSON.stringify({action: "remove", file_id_list: file_id_list, tag_id: tag_id}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("input", "#selection-tags-filter", function (e) {
let filter = $(this).val().toLowerCase();
let unfiltered = $("#selection-tags-other > .item-preview");
if (filter === "") {
unfiltered.css("display", "");
return;
}
unfiltered.each((index, element) => {
let current = $(element);
if (current.text().toLowerCase().includes(filter)) {
current.css("display", "");
} else {
current.css("display", "none");
}
});
});
$(document).on("scroll", $("#viewer").contents(), function (e) {
let pos = $(this).scrollTop();
$(window.parent.document).find(".viewer-nav").css({
top: (-pos) + "px",
bottom: pos + "px"
});
});
$(document).on("click", "#view-next", function (e) {
let id = $(this).attr("file_id");
if (!id) {
return;
}
let curr = $(`.file-preview[file_id='${id}']`)
$("#viewer").attr("src", "/files/" + id);
$("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id"));
$("#view-next").attr("file_id", curr.next(":visible").attr("file_id"));
$(".viewer-wrapper").css("display", "");
});
$(document).on("click", "#view-prev", function (e) {
let id = $(this).attr("file_id");
if (!id) {
return;
}
let curr = $(`.file-preview[file_id='${id}']`)
$("#viewer").attr("src", "/files/" + id);
$("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id"));
$("#view-next").attr("file_id", curr.next(":visible").attr("file_id"));
$(".viewer-wrapper").css("display", "");
});
$(document).on("click", "#view-close", function (e) {
$(".viewer-wrapper").css("display", "none");
});
-18
View File
@@ -1,18 +0,0 @@
$(window).on("load", function () {
$.ajax({
url: "/api/get_my_sessions",
type: "GET",
contentType: "application/json",
success: function (resp) {
let timezone_offset = new Date().getTimezoneOffset();
resp.forEach((session) => {
let s_started = beautify_date(session.started);
let s_expires = beautify_date(session.expires);
$("#sessions-table").append(`<tr><td>${session.user_agent_name}</td><td>${s_started}</td><td>${s_expires === null ? "-" : session.expires}</td><td align="right"><img src="/static/images/icon-terminate.svg" alt="Terminate" class="btn-terminate" session_id="${session.id}"></td></tr>`);
});
},
failure: function (err) {
alert(err);
}
});
});
-89
View File
@@ -1,89 +0,0 @@
$(document).on("input", "#parent-tags-filter", function (e) {
let filter = $(this).val().toLowerCase();
let unfiltered = $("#parent-tags-other > .tag-preview");
if (filter === "") {
unfiltered.css("display", "");
return;
}
unfiltered.each((index, element) => {
let current = $(element);
if (current.text().toLowerCase().includes(filter)) {
current.css("display", "");
} else {
current.css("display", "none");
}
});
});
$(document).on("click", "#parent-tags-other > .tag-preview", function (e) {
$("#loader").css("display", "");
let tag_id = $(this).attr("tag_id");
$.ajax({
url: location.pathname + "/parent",
type: "POST",
contentType: "application/json",
data: JSON.stringify({add: true, tag_id: $(this).attr("tag_id")}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
$(`#parent-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#parent-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("click", "#parent-tags-selected > .tag-preview", function (e) {
$("#loader").css("display", "");
let tag_id = $(this).attr("tag_id");
$.ajax({
url: location.pathname + "/parent",
type: "POST",
contentType: "application/json",
data: JSON.stringify({add: false, tag_id: $(this).attr("tag_id")}),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
$(`#parent-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
$(`#parent-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
$(document).on("submit", "#object-edit", function (e) {
e.preventDefault();
$("#loader").css("display", "");
$.ajax({
url: location.pathname + "/edit",
type: "POST",
data: $(this).serialize(),
dataType: "json",
success: function (resp) {
$("#loader").css("display", "none");
if (resp.status) {
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
} else {
alert(resp.error);
}
},
failure: function (err) {
$("#loader").css("display", "none");
alert(err);
}
});
});
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/images/ms-icon-70x70.png" />
<square150x150logo src="/images/ms-icon-150x150.png" />
<square310x310logo src="/images/ms-icon-310x310.png" />
<TileColor>#615880</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<title>Welcome to Tanabata File Manager!</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
</head>
<body data-bs-theme="dark">
<img src="{{ url_for('static', filename='images/tanabata-left.png') }}" alt="" class="decoration left">
<img src="{{ url_for('static', filename='images/tanabata-right.png') }}" alt="" class="decoration right">
<form id="auth">
<h1>Welcome to Tanabata File Manager!</h1>
<div class="form-group">
<input type="text" class="form-control form-control-lg" id="username" name="username" placeholder="Username..." required>
</div>
<div class="form-group">
<input type="password" class="form-control form-control-lg" id="password" name="password" placeholder="Password..." required>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" id="login">Log in</button>
</div>
</form>
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="57x57" href="{{ url_for('static', filename='images/apple-icon-57x57.png') }}">
<link rel="apple-touch-icon" sizes="60x60" href="{{ url_for('static', filename='images/apple-icon-60x60.png') }}">
<link rel="apple-touch-icon" sizes="72x72" href="{{ url_for('static', filename='images/apple-icon-72x72.png') }}">
<link rel="apple-touch-icon" sizes="76x76" href="{{ url_for('static', filename='images/apple-icon-76x76.png') }}">
<link rel="apple-touch-icon" sizes="114x114" href="{{ url_for('static', filename='images/apple-icon-114x114.png') }}">
<link rel="apple-touch-icon" sizes="120x120" href="{{ url_for('static', filename='images/apple-icon-120x120.png') }}">
<link rel="apple-touch-icon" sizes="144x144" href="{{ url_for('static', filename='images/apple-icon-144x144.png') }}">
<link rel="apple-touch-icon" sizes="152x152" href="{{ url_for('static', filename='images/apple-icon-152x152.png') }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-icon-180x180.png') }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='images/android-icon-192x192.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='images/favicon-96x96.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="manifest" href="/tanabata.webmanifest">
<meta name="msapplication-TileColor" content="#615880">
<meta name="msapplication-TileImage" content="{{ url_for('static', filename='images/ms-icon-144x144.png') }}">
<meta name="theme-color" content="#615880">
<style>
@font-face {
font-family: Epilogue;
src: url({{ url_for('static', filename='fonts/Epilogue-VariableFont_wght.ttf') }});
}
</style>
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/general.css') }}">
<script src="{{ url_for('static', filename='js/jquery-3.6.0.min.js') }}"></script>
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>New category | Tanabata File Manager</title>
</head>
<body data-bs-theme="dark">
<header>
<h1><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Category -" class="icon-header"> New category</h1>
<form id="object-add" method="POST" action="/categories/new">
<div class="row">
<div class="form-group col-md-10">
<label for="name">Name</label>
<input type="text" class="form-control" name="name" id="name" required>
</div>
<div class="form-group col-md-2">
<label for="color">Color</label>
<input type="color" class="form-control form-control-color" name="color" id="color" value="#444455">
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="notes" id="notes" rows="3"></textarea>
</div>
<div class="form-check">
<label for="is_private" class="form-check-label">Is private</label>
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" checked>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add category</button>
</div>
</form>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/add-category.js') }}"></script>
</body>
</html>
@@ -1,82 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>New file | Tanabata File Manager</title>
<style>
.tags-container {
margin: 15px 0;
padding: 10px;
height: 200px;
background-color: #212529;
border: 1px solid #495057;
border-radius: .375rem;
display: flex;
justify-content: space-between;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: scroll;
}
.tags-container:after {
content: "";
flex: auto;
}
</style>
</head>
<body data-bs-theme="dark">
<div class="file">
<a href="/static/files/{{ file['id'] }}.{{ file['extension'] }}" class="preview-link" target="_blank">
<img src="/static/previews/{{ file['id'] }}.{{ file['extension'] }}" alt="{{ file['id'] }}.{{ file['extension'] }}" class="preview-img">
</a>
</div>
<header>
<form id="object-edit">
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="init-notes" rows="3" hidden>{{ file['notes'] }}</textarea>
<textarea class="form-control" id="notes" name="notes" rows="3">{{ file['notes'] }}</textarea>
</div>
<div class="form-group">
<label for="datetime">Datetime</label>
<input type="datetime-local" class="form-control" name="init-datetime" step="1" value="{{ file['datetime'] }}" hidden>
<input type="datetime-local" class="form-control" id="datetime" name="datetime" step="1" value="{{ file['datetime'] }}" required>
</div>
<div class="form-check">
<label for="is-private" class="form-check-label">Is private</label>
<input type="checkbox" class="form-check-input" name="init-is_private" {% if file['is_private'] %}checked{% endif %} hidden>
<input type="checkbox" class="form-check-input" name="is_private" {% if file['is_private'] %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
<div class="file-tags">
<div class="tags-container tags-container-selected" id="file-tags-selected">
{% for tag in tags %}
<div class="tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
{% endfor %}
</div>
<input type="text" class="form-control" id="file-tags-filter" placeholder="Filter tags...">
<div class="tags-container" id="file-tags-other">
{% for tag in tags_all %}
{% if tag not in tags %}
<div class="tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
{% endif %}
{% endfor %}
</div>
</div>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/file.js') }}"></script>
</body>
</html>
-52
View File
@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>New tag | Tanabata File Manager</title>
</head>
<body data-bs-theme="dark">
<header>
<h1><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tag -" class="icon-header"> New tag</h1>
<form id="object-add" method="POST" action="/tags/new">
<div class="row">
<div class="form-group col-md-10">
<label for="name">Name</label>
<input type="text" class="form-control" name="name" id="name" required>
</div>
<div class="form-group col-md-2">
<label for="color">Color</label>
<input type="color" class="form-control form-control-color" name="color" id="color" value="#444455">
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="notes" id="notes" rows="3"></textarea>
</div>
<div class="form-group">
<label for="category">Category</label>
<select name="category_id" class="form-control" id="category">
<option></option>
{% for category in categories %}
<option value="{{ category['id'] }}">{{ category['name'] }}</option>
{% endfor %}
</select>
</div>
<div class="form-check">
<label for="is_private" class="form-check-label">Is private</label>
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" checked>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Add tag</button>
</div>
</form>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/add-tag.js') }}"></script>
</body>
</html>
@@ -1,15 +0,0 @@
{% extends 'section.html' %}
{% set section = 'categories' %}
{% set sorting_options = ['name', 'color', 'created'] %}
{% block Header %}
<div class="filtering-wrapper">
<input type="text" class="form-control filtering" id="filter" placeholder="Filter {{ section }}...">
</div>
{% endblock %}
{% block Main %}
{% for category in categories %}
<div class="item-preview category-preview" category_id="{{ category['id'] }}"{% if category['color'] %} style="background-color: #{{ category['color'] }}"{% endif %}>{{ category['name'] }}</div>
{% endfor %}
{% endblock %}
@@ -1,33 +0,0 @@
{% extends 'section.html' %}
{% set section = 'files' %}
{% set sorting_options = ['mime_name', 'datetime', 'created'] %}
{% block Header %}
<div class="filtering-wrapper">
<div class="filtering-tokens" id="files-filter"></div>
</div>
<div class="filtering-block" style="display: none">
<div class="filtering-operators">
<div class="filtering-token" val="("><i>(</i></div>
<div class="filtering-token" val="&"><i>AND</i></div>
<div class="filtering-token" val="!"><i>NOT</i></div>
<div class="filtering-token" val="|"><i>OR</i></div>
<div class="filtering-token" val=")"><i>)</i></div>
</div>
<div class="filtering-filter">
<input type="text" class="form-control filtering" id="filter-filtering" placeholder="Filter tags...">
</div>
<div class="filtering-tokens" id="filtering-tokens-all">
<div class="filtering-token" val="t=00000000-0000-0000-0000-000000000000"><i>Untagged</i></div>
<div class="filtering-token" val="m~image%"><i>MIME: image/*</i></div>
<div class="filtering-token" val="m~video%"><i>MIME: video/*</i></div>
</div>
<div class="form-group btn-row">
<button class="btn btn-primary" id="filtering-apply" style="width: 48%;">Apply</button>
<button class="btn btn-danger" id="filtering-reset" style="width: 48%;">Reset</button>
</div>
</div>
{% endblock %}
{% block Main %}
{% endblock %}
@@ -1,35 +0,0 @@
{% extends 'section.html' %}
{% set section = 'settings' %}
{% block Main %}
<form id="settings-user">
<h2>User settings</h2>
<div class="row">
<div class="form-group col-md-6">
<label for="username">Username</label>
<input type="text" class="form-control" name="username" id="username" value="{{ 'aboba' }}" required>
</div>
<div class="form-group col-md-6">
<label for="password">Password</label>
<input type="password" class="form-control" name="password" id="password">
</div>
</div>
<div class="form-group" style="margin-top: 14px">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
<div class="sessions-wrapper">
<h2>Sessions</h2>
<table class="table table-dark table-striped">
<thead>
<tr>
<th>User agent</th>
<th>Started</th>
<th>Expires</th>
<th>Terminate</th>
</tr>
</thead>
<tbody id="sessions-table"></tbody>
</table>
</div>
{% endblock %}
@@ -1,15 +0,0 @@
{% extends 'section.html' %}
{% set section = 'tags' %}
{% set sorting_options = ['name', 'color', 'category_name', 'created'] %}
{% block Header %}
<div class="filtering-wrapper">
<input type="text" class="form-control filtering" id="filter" placeholder="Filter {{ section }}...">
</div>
{% endblock %}
{% block Main %}
{% for tag in tags %}
<div class="item-preview tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
{% endfor %}
{% endblock %}
-117
View File
@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<title>{{ section[0]|upper() }}{{ section[1:] }} | Tanabata File Manager</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
{% if section == 'files' %}
<!-- <script src="{{ url_for('static', filename='js/jquery.lazy.min.js') }}"></script> -->
{% endif %}
</head>
<body data-bs-theme="dark">
{% if section != 'settings' %}
<header>
<div class="sorting">
<div class="highlighted" id="select">Select</div>
<div id="sorting">Sorting by <span class="highlighted" id="attribute">{{ sorting['key'] }} ({% if sorting['asc'] %}asc{% else %}desc{% endif %})</span> <img src="{{ url_for('static', filename='images/icon-expand.svg') }}" alt="" id="icon-expand"></div>
<form id="sorting-options" style="display: none">
{% for opt in sorting_options %}
<div class="form-check sorting-option">
<label class="form-check-label" for="{{ opt }}">{{ opt }}</label>
<input type="radio" class="form-check-input" name="sorting" value="{{ opt }}" id="{{ opt }}" {% if opt == sorting['key'] %}checked prev-checked{% endif %}>
</div>
{% endfor %}
<hr>
<div class="form-check sorting-option">
<label class="form-check-label" for="asc">ascending</label>
<input type="radio" class="form-check-input" name="order" value="asc" id="asc" {% if sorting['asc'] %}checked prev-checked{% endif %}>
</div>
<div class="form-check sorting-option">
<label class="form-check-label" for="desc">descending</label>
<input type="radio" class="form-check-input" name="order" value="desc" id="desc" {% if not sorting['asc'] %}checked prev-checked{% endif %}>
</div>
</form>
</div>
{% block Header %}{% endblock %}
</header>
{% endif %}
<main>
{% block Main %}{% endblock %}
</main>
{% if section != 'settings' %}
<div class="selection-manager" style="display: none">
<div class="selection-header">
<div id="selection-info"><span id="selection-count">0</span> selected</div>
{% if section == 'files' %}
<div id="selection-edit-tags">Edit tags</div>
<div id="selection-add-to-pool">Add to pool</div>
{% endif %}
<div id="selection-delete">Delete</div>
</div>
<hr>
{% if section == 'files' %}
<div class="selection-tags" style="display: none">
<div class="tags-container tags-container-selected" id="selection-tags-selected">
{% for tag in tags_all %}
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %};display: none">{{ tag['name'] }}</div>
{% endfor %}
</div>
<input type="text" class="form-control filtering" id="selection-tags-filter" placeholder="Filter tags...">
<div class="tags-container" id="selection-tags-other">
{% for tag in tags_all %}
<div class="item-preview tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if section == 'files' %}
<div class="viewer-wrapper" style="display: none">
<div class="viewer-nav viewer-nav-close" id="view-close">
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: -3px 0;"></div>
</div>
<div class="viewer-nav viewer-nav-prev" id="view-prev">
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: 0-25px;"></div>
</div>
<div class="viewer-nav viewer-nav-next" id="view-next">
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: 0-63px;"></div>
</div>
<iframe src="" frameborder="0" id="viewer"></iframe>
</div>
{% endif %}
<footer>
{% if section == 'categories' %}
<a href="/categories/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add category" class="navicon"></a>
{% else %}
<a href="/categories" class="nav"><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Categories" class="navicon"></a>
{% endif %}
{% if section == 'tags' %}
<a href="/tags/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add tag" class="navicon"></a>
{% else %}
<a href="/tags" class="nav"><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tags" class="navicon"></a>
{% endif %}
{% if section == 'files' %}
<a href="/files/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add file" class="navicon"></a>
{% else %}
<a href="/files" class="nav"><img src="{{ url_for('static', filename='images/icon-file.svg') }}" alt="Files" class="navicon"></a>
{% endif %}
{% if section == 'pools' %}
<a href="/pools/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add pool" class="navicon"></a>
{% else %}
<a href="/pools" class="nav"><img src="{{ url_for('static', filename='images/icon-pool.svg') }}" alt="Pools" class="navicon"></a>
{% endif %}
{% if section == 'settings' %}
<a href="/settings" class="nav curr"><img src="{{ url_for('static', filename='images/icon-settings.svg') }}" alt="Settings" class="navicon"></a>
{% else %}
<a href="/settings" class="nav"><img src="{{ url_for('static', filename='images/icon-settings.svg') }}" alt="Settings" class="navicon"></a>
{% endif %}
</footer>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/'+ section + '.js') }}"></script>
</body>
</html>
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>Category - {{ category['name'] }} | Tanabata File Manager</title>
</head>
<body data-bs-theme="dark">
<header>
<h1><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Category -" class="icon-header"> {{ category['name'] }}</h1>
<form id="object-edit">
<div class="row">
<div class="form-group col-md-10">
<label for="name">Name</label>
<input type="text" class="form-control" name="name" id="name" value="{{ category['name'] }}" required>
</div>
<div class="form-group col-md-2">
<label for="color">Color</label>
<input type="color" class="form-control form-control-color" name="color" id="color" value="#{% if category['color'] %}{{ category['color'] }}{% else %}444455{% endif %}">
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="notes" id="notes" rows="3">{{ category['notes'] }}</textarea>
</div>
<div class="form-check">
<label for="is_private" class="form-check-label">Is private</label>
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" {% if category['is_private'] %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/category.js') }}"></script>
</body>
</html>
@@ -1,57 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>File - {{ file['id'] }}.{{ file['extension'] }} | Tanabata File Manager</title>
</head>
<body data-bs-theme="dark">
<div class="file">
<a href="/static/files/{{ file['id'] }}" class="preview-link" target="_blank">
<img src="/static/previews/{{ file['id'] }}" alt="{{ file['id'] }}" class="preview-img">
</a>
</div>
<header>
<form id="object-edit">
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="init-notes" rows="3" hidden>{{ file['notes'] }}</textarea>
<textarea class="form-control" id="notes" name="notes" rows="3">{{ file['notes'] }}</textarea>
</div>
<div class="form-group">
<label for="datetime">Datetime</label>
<input type="datetime-local" class="form-control" name="init-datetime" step="1" value="{{ file['datetime'] }}" hidden>
<input type="datetime-local" class="form-control" id="datetime" name="datetime" step="1" value="{{ file['datetime'] }}" required>
</div>
<div class="form-check">
<label for="is_private" class="form-check-label">Is private</label>
<input type="checkbox" class="form-check-input" name="init-is_private" {% if file['is_private'] %}checked{% endif %} hidden>
<input type="checkbox" class="form-check-input" id="is_private" name="is_private" {% if file['is_private'] %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
<div class="file-tags">
<div class="tags-container" id="file-tags-selected">
{% for tag in tags_all %}
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag not in tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
{% endfor %}
</div>
<input type="text" class="form-control filtering" id="file-tags-filter" placeholder="Filter tags...">
<div class="tags-container" id="file-tags-other">
{% for tag in tags_all %}
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag in tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
{% endfor %}
</div>
</div>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/file.js') }}"></script>
</body>
</html>
@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% include 'head.html' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
<title>Tag - {{ tag['name'] }} | Tanabata File Manager</title>
</head>
<body data-bs-theme="dark">
<header>
<h1><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tag -" class="icon-header"> {{ tag['name'] }}</h1>
<form id="object-edit">
<div class="row">
<div class="form-group col-md-10">
<label for="name">Name</label>
<input type="text" class="form-control" name="name" id="name" value="{{ tag['name'] }}" required>
</div>
<div class="form-group col-md-2">
<label for="color">Color</label>
<input type="color" class="form-control form-control-color" name="color" id="color" value="#{% if tag['color'] %}{{ tag['color'] }}{% else %}444455{% endif %}">
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea class="form-control" name="notes" id="notes" rows="3">{{ tag['notes'] }}</textarea>
</div>
<div class="form-group">
<label for="category">Category</label>
<select name="category_id" class="form-control" id="category">
<option value="00000000-0000-0000-0000-000000000000"></option>
{% for category in categories %}
<option value="{{ category['id'] }}" {% if category['id'] == tag['category_id'] %}selected{% endif %}>{{ category['name'] }}</option>
{% endfor %}
</select>
</div>
<div class="form-check">
<label for="is_private" class="form-check-label">Is private</label>
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" {% if tag['is_private'] %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
<div class="parent-tags">
<div class="tags-container tags-container-selected" id="parent-tags-selected">
{% for tag in tags_all %}
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag not in parent_tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
{% endfor %}
</div>
<input type="text" class="form-control filtering" id="parent-tags-filter" placeholder="Filter tags...">
<div class="tags-container" id="parent-tags-other">
{% for tag in tags_all %}
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag in parent_tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
{% endfor %}
</div>
</div>
</header>
<div id="loader" style="display: none">
<div class="loader-wrapper">
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
</div>
</div>
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
<script src="{{ url_for('static', filename='js/tag.js') }}"></script>
</body>
</html>
-476
View File
@@ -1,476 +0,0 @@
#!../venv/bin/python3
from flask import Flask, render_template, request, session, jsonify, redirect, url_for, send_from_directory, send_file, abort
from flask_cors import CORS
from ua_parser.user_agent_parser import ParseUserAgent
import sys
from os.path import dirname, abspath, join
sys.path.append(dirname(dirname(abspath(__file__))))
import api.tfm_api as tfm_api
tfm_api.Initialize()
app = Flask("TFM")
CORS(app)
app.secret_key = tfm_api.conf["Flask"]["SecretKey"]
@app.route("/api/<func>", methods=["GET"])
def api(func):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
return redirect("/")
try:
if func == "files":
sorting = session.get("sorting")["files"]
philter = request.args.get("filter")
offset = request.args.get("offset", type=int, default=0)
limit = request.args.get("limit", type=int)
return jsonify(ts.get_files_by_filter(philter, sorting["key"], sorting["asc"], offset, limit))
if func == "tags":
sorting = session.get("sorting")["tags"]
offset = request.args.get("offset", type=int, default=0)
limit = request.args.get("limit", type=int)
return jsonify(ts.get_tags(sorting["key"], sorting["asc"], offset, limit))
if func == "get_my_sessions":
offset = request.args.get("offset", type=int, default=0)
limit = request.args.get("limit", type=int)
return jsonify(ts.get_my_sessions(offset=offset, limit=limit))
if func == "terminate_session":
session_id = request.args.get("id");
if session_id is None:
session_id = ts.sid
return jsonify(), 204
abort(400)
except Exception as e:
print(e)
abort(500, str(e))
@app.route("/favicon.ico")
@app.route("/robots.txt")
@app.route("/tanabata.webmanifest")
@app.route("/browserconfig.xml")
def favicon():
return send_from_directory(join(app.root_path, "static/service"), request.path[1:])
@app.route("/", methods=["GET"])
def index():
if session.get("id"):
return redirect("/files")
return render_template("auth.html")
@app.route("/auth", methods=["POST"])
def auth():
try:
ts = tfm_api.authorize(
request.form.get("username"),
request.form.get("password"),
ParseUserAgent(request.headers.get("User-Agent"))["family"]
)
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
else:
logout()
session["id"] = ts.sid
session["sorting"] = tfm_api.DEFAULT_SORTING
session.permanent = True
session.modified = True
return jsonify({"status": True, "is_admin": ts.is_admin})
@app.route("/logout", methods=["GET"])
def logout():
try:
ts = tfm_api.TSession(session.get("id"))
ts.terminate()
finally:
session.clear()
return redirect("/")
@app.route("/files", methods=["GET"])
def files():
try:
ts = tfm_api.TSession(session.get("id"))
sorting = session.get("sorting")["files"]
sorting_t = session.get("sorting")["tags"]
except Exception as e:
logout()
return redirect("/")
return render_template("section-files.html",
files=ts.get_files(sorting["key"], sorting["asc"], limit=100),
sorting=sorting,
tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"])
)
@app.route("/tags", methods=["GET"])
def tags():
try:
ts = tfm_api.TSession(session.get("id"))
sorting = session.get("sorting")["tags"]
except Exception as e:
logout()
return redirect("/")
return render_template("section-tags.html",
tags=ts.get_tags(sorting["key"], sorting["asc"]),
sorting=sorting
)
@app.route("/categories", methods=["GET"])
def categories():
try:
ts = tfm_api.TSession(session.get("id"))
sorting = session.get("sorting")["categories"]
except Exception as e:
logout()
return redirect("/")
return render_template("section-categories.html",
categories=ts.get_categories(sorting["key"], sorting["asc"]),
sorting=sorting
)
@app.route("/settings", methods=["GET"])
def settings():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
return redirect("/")
return render_template("section-settings.html")
@app.route("/files/<file_id>", methods=["GET"])
def file(file_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
return redirect("/")
try:
file = ts.get_file(file_id)
if not file:
abort(404, "File does not exist")
file["datetime"] = file["datetime"].strftime('%Y-%m-%dT%H:%M:%S')
sorting = session.get("sorting")["tags"]
ts.view_file(file_id)
return render_template("view-file.html",
file=file,
tags=ts.get_tags_by_file(file_id, sorting["key"], sorting["asc"]),
tags_all=ts.get_tags(sorting["key"], sorting["asc"])
)
except Exception as e:
abort(400, str(e).split('\n')[0])
@app.route("/tags/<tag_id>", methods=["GET", "POST"])
def tag(tag_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
if request.method == "POST":
abort(401)
return redirect("/")
try:
tag = ts.get_tag(tag_id)
if not tag:
raise RuntimeError("Tag does not exist")
sorting_c = session.get("sorting")["categories"]
sorting_t = session.get("sorting")["tags"]
return render_template("view-tag.html",
tag=tag,
categories=ts.get_categories(sorting_c["key"], sorting_c["asc"]),
parent_tags=ts.get_parent_tags(tag_id),
tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"])
)
except Exception as e:
abort(400, str(e).split('\n')[0])
@app.route("/categories/<category_id>", methods=["GET", "POST"])
def category(category_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
if request.method == "POST":
abort(401)
return redirect("/")
try:
category = ts.get_category(category_id)
if not category:
raise RuntimeError("Category does not exist")
return render_template("view-category.html",
category=category
)
except Exception as e:
abort(400, str(e).split('\n')[0])
@app.route("/tags/new", methods=["GET", "POST"])
def new_tag():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
if request.method == "POST":
abort(401)
return redirect("/")
if request.method == "POST":
try:
color = request.form.get("color")
if color == "#444455":
color = None
return jsonify({"status": True, "tag_id": ts.add_tag(request.form.get("name").strip(),
request.form.get("notes"),
color,
request.form.get("category_id"),
request.form.get("is_private", False))})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
try:
sorting = session.get("sorting")["categories"]
return render_template("new-tag.html",
categories=ts.get_categories(sorting["key"], sorting["asc"])
)
except Exception as e:
abort(400, str(e).split('\n')[0])
@app.route("/categories/new", methods=["GET", "POST"])
def new_category():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
if request.method == "POST":
abort(401)
return redirect("/")
if request.method == "POST":
try:
color = request.form.get("color")
if color == "#444455":
color = None
return jsonify({"status": True, "tag_id": ts.add_category(request.form.get("name").strip(),
request.form.get("notes"),
color,
request.form.get("is_private", False))})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
try:
return render_template("new-category.html")
except Exception as e:
abort(400, str(e).split('\n')[0])
@app.route("/files/<file_id>/edit", methods=["POST"])
def edit_file(file_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
ts.edit_file(file_id, None, request.form.get("datetime"), request.form.get("notes"), request.form.get("is_private", False))
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/tags/<tag_id>/edit", methods=["POST"])
def edit_tag(tag_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
color = request.form.get("color")
if color == "#444455":
color = ""
ts.edit_tag(tag_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("category_id"), request.form.get("is_private", False))
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/categories/<category_id>/edit", methods=["POST"])
def edit_category(category_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
color = request.form.get("color")
if color == "#444455":
color = ""
ts.edit_category(category_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("is_private", False))
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/files/<file_id>/tag", methods=["POST"])
def file_tags(file_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
req = request.get_json()
if req["add"]:
return jsonify({"status": True, "tags": ts.add_file_to_tag(file_id, req["tag_id"])})
else:
ts.remove_file_to_tag(file_id, req["tag_id"])
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/tags/<tag_id>/parent", methods=["POST"])
def parent_tags(tag_id):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
req = request.get_json()
if req["add"]:
ts.add_autotag(tag_id, req["tag_id"])
return jsonify({"status": True})
else:
ts.remove_autotag(tag_id, req["tag_id"])
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/files/tags", methods=["POST"])
def files_tags():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
req = request.get_json()
if req["action"] == "get":
res = set(map(lambda t: t["id"], ts.get_tags_by_file(req["file_id_list"][0])))
for file_id in req["file_id_list"][1:]:
res &= set(map(lambda t: t["id"], ts.get_tags_by_file(file_id)))
return jsonify({"status": True, "tag_id_list": list(res)})
elif req["action"] == "add":
res = set()
for file_id in req["file_id_list"]:
res |= set(ts.add_file_to_tag(file_id, req["tag_id"]))
return jsonify({"status": True, "tag_id_list": list(res)})
elif req["action"] == "remove":
for file_id in req["file_id_list"]:
ts.remove_file_to_tag(file_id, req["tag_id"])
return jsonify({"status": True})
else:
return jsonify({"status": False, "error": "unsupported action"})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/files/delete", methods=["POST"])
def files_delete():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(401)
try:
req = request.get_json()
for file_id in req["file_id_list"]:
ts.remove_file(file_id)
return jsonify({"status": True})
except Exception as e:
return jsonify({"status": False, "error": str(e).split('\n')[0]})
@app.route("/static/files/<file_id>", methods=["GET"])
def file_full(file_id=None):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(404)
try:
file = ts.get_file(file_id)
if not file:
raise RuntimeError("File does not exist")
return send_file(
join(tfm_api.conf["Paths"]["Files"], file_id),
mimetype=file["mime_name"],
download_name=(file["orig_name"] if file["orig_name"] else "%s.%s" % (file_id, file["extension"]))
)
except:
abort(404)
@app.route("/static/thumbs/<file_id>", methods=["GET"])
def thumb(file_id=None):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(404)
try:
file = ts.get_file(file_id)
if not file:
raise RuntimeError("File does not exist")
return send_file(
tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=160, width=160),
mimetype="image/jpeg"
)
except:
abort(404)
@app.route("/static/previews/<file_id>", methods=["GET"])
def preview(file_id=None):
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
abort(404)
try:
file = ts.get_file(file_id)
if not file:
raise RuntimeError("File does not exist")
return send_file(
tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=1080, width=1920),
mimetype="image/jpeg"
)
except:
abort(404)
@app.route("/settings/sorting", methods=["POST"])
def sorting():
try:
ts = tfm_api.TSession(session.get("id"))
except Exception as e:
logout()
return redirect("/")
req = request.get_json()
session["sorting"].update(req)
session.modified = True
return jsonify({"status": True})
if __name__ == "__main__":
app.run(host=tfm_api.conf["Flask"]["Host"], port=tfm_api.conf["Flask"]["Port"], debug=True)
-148
View File
@@ -1,148 +0,0 @@
## О проекте
Tanabata File Manager или сокращенно TFM — многопользовательский веб-файловый менеджер, организующий файлы по тегам. Работает на клиент-серверной архитектуре, управляется через веб-интерфейс. Главная цель проекта — обеспечить централизованное хранение файлов на сервере, доступ к ним и управление ими через веб как с компьютера, так и со смартфона. В первую очередь данное приложение ориентировано на изображения и видео.
## Общая архитектура
- File storage
- Relational database (PostgreSQL)
- REST API service (Go)
- Frontend (SvelteKit)
Приложение предполагается разворачивать внутри контейнера Docker. Фронтенд и бэкенд - в одном контейнере, СУБД - отдельно (на моем сервере планируется подключать к СУБД на хосте). Все файлы, управляемые Танабатой, будут храниться кучей в одной папке. Имя файла на диске совпадает с его UUID в БД.
Приложение является PWA, которое можно установить на компьютер или смартфон.
В будущих версиях планируется введение поддержки других СУБД.
## Основные понятия
**Файл** — один файл на сервере. Может иметь сколько угодно тегов, может принадлежать скольким угодно пулам. Имеет автора, а также может иметь настройки доступа (пользователь (может быть null - таким образом можно делать файл публичным), флаг права на чтение, флаг права на изменение). Имеет оригинальное название и метаданные (ключ-значение, в том числе все данные EXIF).
**Тег** — метка файла. Может быть привязан к скольким угодно файлам, может быть привязан к одной категории. Имеет название, описание, метаданные (ключ-значение). Может иметь автотеги.
**Автотег** — правило, согласно которому при привязке к файлу условного тега А к этому же файлу автоматически привязывается условный тег Б.
**Категория** — сущность, логически объединяющая собой несколько тегов. Имеет название, описание, метаданные (ключ-значение).
**Пул** — логическое объединение файлов. Имеет название, описание, метаданные (ключ-значение). Файлы внутри могут быть как отсортированы автоматически, так и расположены в порядке, заданном пользователем вручную.
## Функциональные требования
1. Управление файлами
1. Просмотр списка файлов (lazy load, pagination)
2. Фильтрация файлов по тегам и метаданным
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких файлов (Ctrl, Shift) и действия с ними
1. Привязка/отвязка тегов
2. Копирование/вставка тегов
3. Добавление в пул
4. Просмотр и редактирование настроек доступа
5. Удаление (с запросом подтверждения)
5. Просмотр одного файла
6. Действия с одним файлом
1. Привязка/отвязка тегов
2. Копирование/вставка тегов
3. Добавление в пул
4. Просмотр и редактирование настроек доступа
5. Замена файла (загрузка нового под таким же ID)
6. Удаление (с запросом подтверждения)
7. Листание файлов, как в галерее
8. Загрузка новых файлов через веб-интерфейс (через форму или drag-n-drop прямо на список)
9. Импорт новых файлов из папки на сервере
10. Выявление дубликатов, в частности, изображений и видео
1. Отображение групп дубликатов
2. Возможность отвязывания фальшивых дубликатов (чтобы приложение запомнило, что изображение А не является дубликатом изображения Б)
3. Возможность выбора дубликата для удаления/сохранения
4. Возможность выбора, какие поля от какого дубликата подтягивать
11. Корзина
1. Просмотр файлов в корзине
2. Восстановление из корзины
3. Окончательное удаление
2. Управление тегами
1. Просмотр списка тегов (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких тегов (Ctrl, Shift) и действия с ними
1. Назначение автотегов
2. Изменение категории
3. Удаление (с запросом подтверждения)
5. Просмотр одного тега
6. Действия с одним тегом
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Изменение категории
3. Назначение автотегов
4. Удаление (с запросом подтверждения)
7. Создание тега
1. Внесение названия, описания и метаданных (ключ-значение)
2. Назначение категории (опционально)
3. Назначение автотегов
3. Управление категориями
1. Просмотр списка категорий (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких категорий (Ctrl, Shift) и действия с ними
1. Просмотр привязанных общих тегов и тегов, привязанных к некоторым, но не ко всем
2. Привязка/отвязка тегов
3. Удаление (с запросом подтверждения)
5. Просмотр одной категории
6. Действия с одной категорией
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Просмотр привязанных тегов
3. Привязка/отвязка тегов
4. Удаление (с запросом подтверждения)
7. Создание категории
1. Внесение названия, описания и метаданных (ключ-значение)
2. Привязка тегов
4. Управление пулами
1. Просмотр списка пулов (lazy load, pagination)
2. Поиск по названию
3. Просмотр и редактирование настроек сортировки (сохраняется для каждого пользователя)
4. Выделение нескольких пулов (Ctrl, Shift) и действия с ними
1. Просмотр и редактирование настроек доступа
2. Удаление (с запросом подтверждения)
5. Просмотр одного пула
6. Действия с одним пулом
1. Редактирование названия, описания и метаданных (ключ-значение)
2. Просмотр и редактирование настроек доступа
3. Просмотр всех файлов, входящих в пул
4. Фильтрация файлов по тегам
5. Изменение настройки сортировки файлов (в том числе можно отключить автоматическую сортировку)
6. Ручное изменение порядка файлов (при отключенной сортировке)
7. Удаление (с запросом подтверждения)
7. Создание категории
1. Внесение названия, описания и метаданных (ключ-значение)
2. Привязка тегов
5. Управление пользовательскими настройками
1. Имя пользователя
2. Пароль
3. Сессии
1. Завершение сессии
4. Путь к папке на сервере, которая будет сканироваться при импорта файлов
6. Управление настройками сервера (админка)
1. Пользователи
1. Просмотр списка
2. Просмотр одного
3. Создание
4. Удаление
5. Блокировка/разблокировка
6. Установка роли (читатель/редактор)
7. Журналирование пользовательских действий в БД
1. Просмотры файлов
2. Смены настроек доступа к файлам
3. Создание/редактирование/удаление файла, тега, категории, пула, связи файл-тег
4. Создание/блокировка/разблокировка/удаление пользователя
5. Смена роли пользователя
6. Авторизация/логаут пользователя
7. Завершение сессии
## Нефункциональные требования
1. Интерфейс должен быть максимально простым и удобным, все необходимое должно быть под рукой, доступным за минимальное количество действий
2. Интерфейс должен быть адаптирован под десктоп и под мобильные устройства
3. Интерфейс должен иметь темную и светлую темы
4. Использование технологии PWA (также должна быть кнопка, при нажатии которой PWA будет полностью сбрасываться (кроме кэша) и заново загружаться с сервера)
5. Возможность сохранять некоторые файлы в кэш и просматривать их оффлайн при использовании установленного PWA
6. При первичном запуске приложение должно требовать минимума действий: автоматическая миграция БД, заранее готовый файл docker compose, файл .env с настраиваемыми параметрами установки
7. Использование подхода DDD для сервера API
8. Не принимать файлы, чей MIME отсутствует в БД (нет в БД — нет поддержки)
+103
View File
@@ -0,0 +1,103 @@
// Tanabata file manager core names
// By Masahiko AMANO aka H1K0
#pragma once
#ifndef TANABATA_CORE_H
#define TANABATA_CORE_H
#ifdef __cplusplus
#include <cstdint>
#include <cstdio>
extern "C" {
#else
#include <stdint.h>
#include <stdio.h>
#endif
// ==================== STRUCTS ==================== //
// Sasa (笹) - a file record
typedef struct sasa {
uint64_t id; // Sasa ID
uint64_t created_ts; // Sasa creation timestamp
char *path; // File path
} Sasa;
// Tanzaku (短冊) - a tag record
typedef struct tanzaku {
uint64_t id; // Tanzaku ID
uint64_t created_ts; // Tanzaku creation timestamp
uint64_t modified_ts; // Tanzaku last modification timestamp
char *name; // Tanzaku name
char *description; // Tanzaku description
} Tanzaku;
// Kazari (飾り) - a sasa-tanzaku association record
typedef struct kazari {
uint64_t sasa_id; // Sasa ID
uint64_t tanzaku_id; // Tanzaku ID
uint64_t created_ts; // Kazari creation timestamp
} Kazari;
// Sasahyou (笹表) - database of sasa
typedef struct sasahyou {
uint64_t created_ts; // Sasahyou creation timestamp
uint64_t modified_ts; // Sasahyou last modification timestamp
uint64_t size; // Sasahyou size (including holes)
Sasa *database; // Array of sasa
uint64_t hole_cnt; // Number of holes
Sasa **holes; // Array of pointers to holes
FILE *file; // Storage file for sasahyou
} Sasahyou;
// Sappyou (冊表) - database of tanzaku
typedef struct sappyou {
uint64_t created_ts; // Sappyou creation timestamp
uint64_t modified_ts; // Sappyou last modification timestamp
uint64_t size; // Sappyou size (including holes)
Tanzaku *database; // Array of tanzaku
uint64_t hole_cnt; // Number of holes
Tanzaku **holes; // Array of pointers to holes
FILE *file; // Storage file for sappyou
} Sappyou;
// Shoppyou (飾表) - database of kazari
typedef struct shoppyou {
uint64_t created_ts; // Shoppyou creation timestamp
uint64_t modified_ts; // Shoppyou last modification timestamp
uint64_t size; // Shoppyou size (including holes)
Kazari *database; // Array of kazari
uint64_t hole_cnt; // Number of holes
Kazari **holes; // Array of pointers to holes
FILE *file; // Storage file for shoppyou
} Shoppyou;
// Tanabata (七夕) - the struct with all databases
typedef struct tanabata {
Sasahyou sasahyou; // Sasahyou struct
Sappyou sappyou; // Sappyou struct
Shoppyou shoppyou; // Shoppyou struct
uint64_t sasahyou_mod; // Sasahyou file last modificaton timestamp
uint64_t sappyou_mod; // Sappyou file last modificaton timestamp
uint64_t shoppyou_mod; // Shoppyou file last modificaton timestamp
} Tanabata;
// ==================== CONSTANTS ==================== //
// ID of hole - an invalid record
#define HOLE_ID (-1)
// Hole sasa constant with hole ID
extern const Sasa HOLE_SASA;
// Hole tanzaku constant with hole ID
extern const Tanzaku HOLE_TANZAKU;
// Hole kazari constant with hole ID
extern const Kazari HOLE_KAZARI;
#ifdef __cplusplus
}
#endif
#endif //TANABATA_CORE_H
+86
View File
@@ -0,0 +1,86 @@
// Tanabata lib
// By Masahiko AMANO aka H1K0
#pragma once
#ifndef TANABATA_H
#define TANABATA_H
#ifdef __cplusplus
#include <cstdint>
extern "C" {
#else
#include <stdint.h>
#endif
#include "core.h"
// ==================== DATABASE SECTION ==================== //
// Initialize empty tanabata
int tanabata_init(Tanabata *tanabata);
// Free tanabata
int tanabata_free(Tanabata *tanabata);
// Weed tanabata
int tanabata_weed(Tanabata *tanabata);
// Load tanabata
int tanabata_load(Tanabata *tanabata);
// Save tanabata
int tanabata_save(Tanabata *tanabata);
// Open tanabata
int tanabata_open(Tanabata *tanabata, const char *path);
// Dump tanabata
int tanabata_dump(Tanabata *tanabata, const char *path);
// ==================== SASA SECTION ==================== //
// Add sasa
Sasa tanabata_sasa_add(Tanabata *tanabata, const char *path);
// Remove sasa by ID
int tanabata_sasa_rem(Tanabata *tanabata, uint64_t sasa_id);
// Update sasa file path
int tanabata_sasa_upd(Tanabata *tanabata, uint64_t sasa_id, const char *path);
// Get sasa by ID
Sasa tanabata_sasa_get(Tanabata *tanabata, uint64_t sasa_id);
// ==================== TANZAKU SECTION ==================== //
// Add tanzaku
Tanzaku tanabata_tanzaku_add(Tanabata *tanabata, const char *name, const char *description);
// Remove tanzaku by ID
int tanabata_tanzaku_rem(Tanabata *tanabata, uint64_t tanzaku_id);
// Update tanzaku name and description
int tanabata_tanzaku_upd(Tanabata *tanabata, uint64_t tanzaku_id, const char *name, const char *description);
// Get tanzaku by ID
Tanzaku tanabata_tanzaku_get(Tanabata *tanabata, uint64_t tanzaku_id);
// ==================== KAZARI SECTION ==================== //
// Add kazari
int tanabata_kazari_add(Tanabata *tanabata, uint64_t sasa_id, uint64_t tanzaku_id);
// Remove kazari
int tanabata_kazari_rem(Tanabata *tanabata, uint64_t sasa_id, uint64_t tanzaku_id);
// Get tanzaku list of sasa
Tanzaku *tanabata_tanzaku_get_by_sasa(Tanabata *tanabata, uint64_t sasa_id);
// Get sasa list of tanzaku
Sasa *tanabata_sasa_get_by_tanzaku(Tanabata *tanabata, uint64_t tanzaku_id);
#ifdef __cplusplus
}
#endif
#endif //TANABATA_H
+27
View File
@@ -0,0 +1,27 @@
// Tanabata DBMS client lib
// By Masahiko AMANO aka H1K0
#pragma once
#ifndef TANABATA_DBMS_CLIENT_H
#define TANABATA_DBMS_CLIENT_H
#ifdef __cplusplus
extern "C" {
#endif
#include "tdbms.h"
// Connect to TDBMS server
int tdbms_connect(const char *domain, const char *addr);
// Close connection to TDBMS server
int tdbms_close(int socket_fd);
// Execute a TDB request
char *tdb_query(int socket_fd, const char *db_name, char request_code, const char *request_body);
#ifdef __cplusplus
}
#endif
#endif //TANABATA_DBMS_CLIENT_H
+58
View File
@@ -0,0 +1,58 @@
// Tanabata DBMS core names
// By Masahiko AMANO aka H1K0
#pragma once
#ifndef TANABATA_DBMS_H
#define TANABATA_DBMS_H
#ifdef __cplusplus
extern "C" {
#endif
// ASCII End Of Transmission code
#define EOT 4
// TDBMS request code bits
enum TRC_BITS {
trc_bit_remove = 0b1,
trc_bit_add = 0b10,
trc_bit_update = 0b100,
trc_bit_kazari = 0b1000,
trc_bit_sasa = 0b10000,
trc_bit_tanzaku = 0b100000,
};
// TDBMS request codes
enum TRC {
trc_db_stats = 0b0,
trc_db_init = 0b11,
trc_db_load = 0b10,
trc_db_save = 0b100,
trc_db_edit = 0b110,
trc_db_remove_soft = 0b1,
trc_db_remove_hard = 0b101,
trc_db_weed = 0b111,
trc_sasa_get = 0b10000,
trc_sasa_get_by_tanzaku = 0b101000,
trc_sasa_add = 0b10010,
trc_sasa_update = 0b10100,
trc_sasa_remove = 0b10001,
trc_tanzaku_get = 0b100000,
trc_tanzaku_get_by_sasa = 0b11000,
trc_tanzaku_add = 0b100010,
trc_tanzaku_update = 0b100100,
trc_tanzaku_remove = 0b100001,
trc_kazari_get = 0b1000,
trc_kazari_add = 0b1010,
trc_kazari_add_single_sasa_to_multiple_tanzaku = 0b11010,
trc_kazari_add_single_tanzaku_to_multiple_sasa = 0b101010,
trc_kazari_remove = 0b1001,
trc_kazari_remove_single_sasa_to_multiple_tanzaku = 0b11001,
trc_kazari_remove_single_tanzaku_to_multiple_sasa = 0b101001,
};
#ifdef __cplusplus
}
#endif
#endif //TANABATA_DBMS_H
-2033
View File
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
// Tanabata file manager core functions
// By Masahiko AMANO aka H1K0
#pragma once
#ifndef TANABATA_CORE_FUNC_H
#define TANABATA_CORE_FUNC_H
#ifdef __cplusplus
#include <cstdint>
extern "C" {
#else
#include <stdint.h>
#endif
#include "../../include/core.h"
// ==================== SASAHYOU SECTION ==================== //
// Initialize empty sasahyou
int sasahyou_init(Sasahyou *sasahyou);
// Free sasahyou
int sasahyou_free(Sasahyou *sasahyou);
// Load sasahyou from file
int sasahyou_load(Sasahyou *sasahyou);
// Save sasahyou to file
int sasahyou_save(Sasahyou *sasahyou);
// Open sasahyou file and load data from it
int sasahyou_open(Sasahyou *sasahyou, const char *path);
// Dump sasahyou to file
int sasahyou_dump(Sasahyou *sasahyou, const char *path);
// Add sasa to sasahyou
Sasa sasa_add(Sasahyou *sasahyou, const char *path);
// Remove sasa from sasahyou
int sasa_rem(Sasahyou *sasahyou, uint64_t sasa_id);
// Update sasa file path
int sasa_upd(Sasahyou *sasahyou, uint64_t sasa_id, const char *path);
// ==================== SAPPYOU SECTION ==================== //
// Initialize empty sappyou
int sappyou_init(Sappyou *sappyou);
// Free sappyou
int sappyou_free(Sappyou *sappyou);
// Load sappyou from file
int sappyou_load(Sappyou *sappyou);
// Save sappyou to file
int sappyou_save(Sappyou *sappyou);
// Open sappyou file and load data from it
int sappyou_open(Sappyou *sappyou, const char *path);
// Dump sappyou to file
int sappyou_dump(Sappyou *sappyou, const char *path);
// Add new tanzaku to sappyou
Tanzaku tanzaku_add(Sappyou *sappyou, const char *name, const char *description);
// Remove tanzaku from sappyou
int tanzaku_rem(Sappyou *sappyou, uint64_t tanzaku_id);
// Update tanzaku name and description
int tanzaku_upd(Sappyou *sappyou, uint64_t tanzaku_id, const char *name, const char *description);
// ==================== SHOPPYOU SECTION ==================== //
// Initialize empty shoppyou
int shoppyou_init(Shoppyou *shoppyou);
// Free shoppyou
int shoppyou_free(Shoppyou *shoppyou);
// Load shoppyou from file
int shoppyou_load(Shoppyou *shoppyou);
// Save shoppyou to file
int shoppyou_save(Shoppyou *shoppyou);
// Open shoppyou file and load data from it
int shoppyou_open(Shoppyou *shoppyou, const char *path);
// Dump shoppyou to file
int shoppyou_dump(Shoppyou *shoppyou, const char *path);
// Add kazari to shoppyou
int kazari_add(Shoppyou *shoppyou, uint64_t sasa_id, uint64_t tanzaku_id);
// Remove kazari from shoppyou
int kazari_rem(Shoppyou *shoppyou, uint64_t sasa_id, uint64_t tanzaku_id);
// Remove all kazari with a specific sasa ID from shoppyou
int kazari_rem_by_sasa(Shoppyou *shoppyou, uint64_t sasa_id);
// Remove all kazari with a specific tanzaku ID from shoppyou
int kazari_rem_by_tanzaku(Shoppyou *shoppyou, uint64_t tanzaku_id);
#ifdef __cplusplus
}
#endif
#endif //TANABATA_CORE_FUNC_H
+222
View File
@@ -0,0 +1,222 @@
#include <stdint.h>
#include <malloc.h>
#include <string.h>
#include <time.h>
#include "core_func.h"
const Tanzaku HOLE_TANZAKU = {HOLE_ID, 0, 0, NULL, NULL};
// Sappyou file signature: 七夕冊表
const uint16_t SAPPYOU_SIG[4] = {L'', L'', L'', L''};
int sappyou_init(Sappyou *sappyou) {
sappyou->created_ts = time(NULL);
sappyou->modified_ts = sappyou->created_ts;
sappyou->size = 0;
sappyou->database = NULL;
sappyou->hole_cnt = 0;
sappyou->holes = NULL;
sappyou->file = NULL;
return 0;
}
int sappyou_free(Sappyou *sappyou) {
sappyou->created_ts = 0;
sappyou->modified_ts = 0;
sappyou->size = 0;
sappyou->hole_cnt = 0;
if (sappyou->database != NULL) {
for (Tanzaku *current_tanzaku = sappyou->database + sappyou->size - 1;
current_tanzaku >= sappyou->database; current_tanzaku--) {
free(current_tanzaku->name);
free(current_tanzaku->description);
}
free(sappyou->database);
sappyou->database = NULL;
}
free(sappyou->holes);
sappyou->holes = NULL;
if (sappyou->file != NULL) {
fclose(sappyou->file);
sappyou->file = NULL;
}
return 0;
}
int sappyou_load(Sappyou *sappyou) {
if (sappyou->file == NULL ||
(sappyou->file = freopen(NULL, "rb", sappyou->file)) == NULL) {
return 1;
}
Sappyou temp;
sappyou_init(&temp);
temp.file = sappyou->file;
uint16_t signature[4];
if (fread(signature, 2, 4, temp.file) != 4 ||
memcmp(signature, SAPPYOU_SIG, 8) != 0 ||
fread(&temp.created_ts, 8, 1, temp.file) != 1 ||
fread(&temp.modified_ts, 8, 1, temp.file) != 1 ||
fread(&temp.size, 8, 1, temp.file) != 1 ||
fread(&temp.hole_cnt, 8, 1, temp.file) != 1) {
return 1;
}
temp.database = calloc(temp.size, sizeof(Tanzaku));
temp.holes = calloc(temp.hole_cnt, sizeof(Tanzaku *));
size_t max_string_len = SIZE_MAX;
Tanzaku *current_tanzaku = temp.database;
for (uint64_t i = 0, r = temp.hole_cnt; i < temp.size; i++, current_tanzaku++) {
if (fgetc(temp.file) != 0) {
current_tanzaku->id = i;
if (fread(&current_tanzaku->created_ts, 8, 1, temp.file) != 1 ||
fread(&current_tanzaku->modified_ts, 8, 1, temp.file) != 1 ||
getdelim(&current_tanzaku->name, &max_string_len, 0, temp.file) == -1 ||
getdelim(&current_tanzaku->description, &max_string_len, 0, temp.file) == -1) {
temp.file = NULL;
sappyou_free(&temp);
return 1;
}
} else {
current_tanzaku->id = HOLE_ID;
if (r == 0) {
temp.file = NULL;
sappyou_free(&temp);
return 1;
}
r--;
temp.holes[r] = current_tanzaku;
}
}
if (fflush(temp.file) == 0) {
sappyou->file = NULL;
sappyou_free(sappyou);
*sappyou = temp;
return 0;
}
temp.file = NULL;
sappyou_free(&temp);
return 1;
}
int sappyou_save(Sappyou *sappyou) {
if (sappyou->file == NULL ||
(sappyou->file = freopen(NULL, "wb", sappyou->file)) == NULL ||
fwrite(SAPPYOU_SIG, 2, 4, sappyou->file) != 4 ||
fwrite(&sappyou->created_ts, 8, 1, sappyou->file) != 1 ||
fwrite(&sappyou->modified_ts, 8, 1, sappyou->file) != 1 ||
fwrite(&sappyou->size, 8, 1, sappyou->file) != 1 ||
fwrite(&sappyou->hole_cnt, 8, 1, sappyou->file) != 1 ||
fflush(sappyou->file) != 0) {
return 1;
}
Tanzaku *current_tanzaku = sappyou->database;
for (uint64_t i = 0; i < sappyou->size; i++, current_tanzaku++) {
if (current_tanzaku->id != HOLE_ID) {
if (fputc(0xff, sappyou->file) == EOF ||
fwrite(&current_tanzaku->created_ts, 8, 1, sappyou->file) != 1 ||
fwrite(&current_tanzaku->modified_ts, 8, 1, sappyou->file) != 1 ||
fputs(current_tanzaku->name, sappyou->file) == EOF ||
fputc(0, sappyou->file) == EOF ||
fputs(current_tanzaku->description, sappyou->file) == EOF ||
fputc(0, sappyou->file) == EOF) {
return 1;
}
} else if (fputc(0, sappyou->file) == EOF) {
return 1;
}
}
return fflush(sappyou->file);
}
int sappyou_open(Sappyou *sappyou, const char *path) {
if (path == NULL) {
return 1;
}
if (sappyou->file == NULL && (sappyou->file = fopen(path, "rb")) == NULL ||
sappyou->file != NULL && (sappyou->file = freopen(path, "rb", sappyou->file)) == NULL) {
return 1;
}
return sappyou_load(sappyou);
}
int sappyou_dump(Sappyou *sappyou, const char *path) {
if (path == NULL) {
return 1;
}
if (sappyou->file == NULL && (sappyou->file = fopen(path, "wb")) == NULL ||
sappyou->file != NULL && (sappyou->file = freopen(path, "wb", sappyou->file)) == NULL) {
return 1;
}
return sappyou_save(sappyou);
}
Tanzaku tanzaku_add(Sappyou *sappyou, const char *name, const char *description) {
if (name == NULL || description == NULL || sappyou->size == -1 && sappyou->hole_cnt == 0) {
return HOLE_TANZAKU;
}
Tanzaku newbie;
newbie.created_ts = time(NULL);
newbie.modified_ts = newbie.created_ts;
newbie.name = malloc(strlen(name) + 1);
strcpy(newbie.name, name);
newbie.description = malloc(strlen(description) + 1);
strcpy(newbie.description, description);
if (sappyou->hole_cnt > 0) {
sappyou->hole_cnt--;
Tanzaku **hole_ptr = sappyou->holes + sappyou->hole_cnt;
newbie.id = *hole_ptr - sappyou->database;
**hole_ptr = newbie;
sappyou->holes = reallocarray(sappyou->holes, sappyou->hole_cnt, sizeof(Tanzaku *));
} else {
newbie.id = sappyou->size;
sappyou->size++;
sappyou->database = reallocarray(sappyou->database, sappyou->size, sizeof(Tanzaku));
sappyou->database[newbie.id] = newbie;
}
sappyou->modified_ts = newbie.created_ts;
return newbie;
}
int tanzaku_rem(Sappyou *sappyou, uint64_t tanzaku_id) {
if (tanzaku_id == HOLE_ID || tanzaku_id >= sappyou->size) {
return 1;
}
Tanzaku *current_tanzaku = sappyou->database + tanzaku_id;
if (current_tanzaku->id == HOLE_ID) {
return 1;
}
current_tanzaku->id = HOLE_ID;
free(current_tanzaku->name);
free(current_tanzaku->description);
if (tanzaku_id == sappyou->size - 1) {
sappyou->size--;
sappyou->database = reallocarray(sappyou->database, sappyou->size, sizeof(Tanzaku));
} else {
sappyou->hole_cnt++;
sappyou->holes = reallocarray(sappyou->holes, sappyou->hole_cnt, sizeof(Tanzaku *));
sappyou->holes[sappyou->hole_cnt - 1] = current_tanzaku;
}
sappyou->modified_ts = time(NULL);
return 0;
}
int tanzaku_upd(Sappyou *sappyou, uint64_t tanzaku_id, const char *name, const char *description) {
if (tanzaku_id == HOLE_ID || tanzaku_id >= sappyou->size || name == NULL && description == NULL) {
return 1;
}
Tanzaku *current_tanzaku = sappyou->database + tanzaku_id;
if (current_tanzaku->id == HOLE_ID) {
return 1;
}
if (name != NULL) {
current_tanzaku->name = realloc(current_tanzaku->name, strlen(name) + 1);
strcpy(current_tanzaku->name, name);
}
if (description != NULL) {
current_tanzaku->description = realloc(current_tanzaku->description, strlen(description) + 1);
strcpy(current_tanzaku->description, description);
}
sappyou->modified_ts = time(NULL);
current_tanzaku->modified_ts = sappyou->modified_ts;
return 0;
}
+206
View File
@@ -0,0 +1,206 @@
#include <stdint.h>
#include <malloc.h>
#include <string.h>
#include <time.h>
#include "core_func.h"
const Sasa HOLE_SASA = {HOLE_ID, 0, NULL};
// Sasahyou file signature: 七夕笹表
const uint16_t SASAHYOU_SIG[4] = {L'', L'', L'', L''};
int sasahyou_init(Sasahyou *sasahyou) {
sasahyou->created_ts = time(NULL);
sasahyou->modified_ts = sasahyou->created_ts;
sasahyou->size = 0;
sasahyou->database = NULL;
sasahyou->hole_cnt = 0;
sasahyou->holes = NULL;
sasahyou->file = NULL;
return 0;
}
int sasahyou_free(Sasahyou *sasahyou) {
sasahyou->created_ts = 0;
sasahyou->modified_ts = 0;
sasahyou->size = 0;
sasahyou->hole_cnt = 0;
if (sasahyou->database != NULL) {
for (Sasa *current_sasa = sasahyou->database + sasahyou->size - 1;
current_sasa >= sasahyou->database; current_sasa--) {
free(current_sasa->path);
}
free(sasahyou->database);
sasahyou->database = NULL;
}
free(sasahyou->holes);
sasahyou->holes = NULL;
if (sasahyou->file != NULL) {
fclose(sasahyou->file);
sasahyou->file = NULL;
}
return 0;
}
int sasahyou_load(Sasahyou *sasahyou) {
if (sasahyou->file == NULL ||
(sasahyou->file = freopen(NULL, "rb", sasahyou->file)) == NULL) {
return 1;
}
Sasahyou temp;
sasahyou_init(&temp);
temp.file = sasahyou->file;
uint16_t signature[4];
if (fread(signature, 2, 4, temp.file) != 4 ||
memcmp(signature, SASAHYOU_SIG, 8) != 0 ||
fread(&temp.created_ts, 8, 1, temp.file) != 1 ||
fread(&temp.modified_ts, 8, 1, temp.file) != 1 ||
fread(&temp.size, 8, 1, temp.file) != 1 ||
fread(&temp.hole_cnt, 8, 1, temp.file) != 1) {
return 1;
}
temp.database = calloc(temp.size, sizeof(Sasa));
temp.holes = calloc(temp.hole_cnt, sizeof(Sasa *));
size_t max_path_len = SIZE_MAX;
Sasa *current_sasa = temp.database;
for (uint64_t i = 0, r = temp.hole_cnt; i < temp.size; i++, current_sasa++) {
if (fgetc(temp.file) != 0) {
current_sasa->id = i;
if (fread(&current_sasa->created_ts, 8, 1, temp.file) != 1 ||
getdelim(&current_sasa->path, &max_path_len, 0, temp.file) == -1) {
temp.file = NULL;
sasahyou_free(&temp);
return 1;
}
} else {
current_sasa->id = HOLE_ID;
if (r == 0) {
temp.file = NULL;
sasahyou_free(&temp);
return 1;
}
r--;
temp.holes[r] = current_sasa;
}
}
if (fflush(temp.file) == 0) {
sasahyou->file = NULL;
sasahyou_free(sasahyou);
*sasahyou = temp;
return 0;
}
temp.file = NULL;
sasahyou_free(&temp);
return 1;
}
int sasahyou_save(Sasahyou *sasahyou) {
if (sasahyou->file == NULL ||
(sasahyou->file = freopen(NULL, "wb", sasahyou->file)) == NULL ||
fwrite(SASAHYOU_SIG, 2, 4, sasahyou->file) != 4 ||
fwrite(&sasahyou->created_ts, 8, 1, sasahyou->file) != 1 ||
fwrite(&sasahyou->modified_ts, 8, 1, sasahyou->file) != 1 ||
fwrite(&sasahyou->size, 8, 1, sasahyou->file) != 1 ||
fwrite(&sasahyou->hole_cnt, 8, 1, sasahyou->file) != 1 ||
fflush(sasahyou->file) != 0) {
return 1;
}
Sasa *current_sasa = sasahyou->database;
for (uint64_t i = 0; i < sasahyou->size; i++, current_sasa++) {
if (current_sasa->id != HOLE_ID) {
if (fputc(0xff, sasahyou->file) == EOF ||
fwrite(&current_sasa->created_ts, 8, 1, sasahyou->file) != 1 ||
fputs(current_sasa->path, sasahyou->file) == EOF ||
fputc(0, sasahyou->file) == EOF) {
return 1;
}
} else if (fputc(0, sasahyou->file) == EOF) {
return 1;
}
}
return fflush(sasahyou->file);
}
int sasahyou_open(Sasahyou *sasahyou, const char *path) {
if (path == NULL) {
return 1;
}
if (sasahyou->file == NULL && (sasahyou->file = fopen(path, "rb")) == NULL ||
sasahyou->file != NULL && (sasahyou->file = freopen(path, "rb", sasahyou->file)) == NULL) {
return 1;
}
return sasahyou_load(sasahyou);
}
int sasahyou_dump(Sasahyou *sasahyou, const char *path) {
if (path == NULL) {
return 1;
}
if (sasahyou->file == NULL && (sasahyou->file = fopen(path, "wb")) == NULL ||
sasahyou->file != NULL && (sasahyou->file = freopen(path, "wb", sasahyou->file)) == NULL) {
return 1;
}
return sasahyou_save(sasahyou);
}
Sasa sasa_add(Sasahyou *sasahyou, const char *path) {
if (path == NULL || sasahyou->size == -1 && sasahyou->hole_cnt == 0) {
return HOLE_SASA;
}
Sasa newbie;
newbie.created_ts = time(NULL);
newbie.path = malloc(strlen(path) + 1);
strcpy(newbie.path, path);
if (sasahyou->hole_cnt > 0) {
sasahyou->hole_cnt--;
Sasa **hole_ptr = sasahyou->holes + sasahyou->hole_cnt;
newbie.id = *hole_ptr - sasahyou->database;
**hole_ptr = newbie;
sasahyou->holes = reallocarray(sasahyou->holes, sasahyou->hole_cnt, sizeof(Sasa *));
} else {
newbie.id = sasahyou->size;
sasahyou->size++;
sasahyou->database = reallocarray(sasahyou->database, sasahyou->size, sizeof(Sasa));
sasahyou->database[newbie.id] = newbie;
}
sasahyou->modified_ts = newbie.created_ts;
return newbie;
}
int sasa_rem(Sasahyou *sasahyou, uint64_t sasa_id) {
if (sasa_id == HOLE_ID || sasa_id >= sasahyou->size) {
return 1;
}
Sasa *current_sasa = sasahyou->database + sasa_id;
if (current_sasa->id == HOLE_ID) {
return 1;
}
current_sasa->id = HOLE_ID;
free(current_sasa->path);
current_sasa->path = NULL;
if (sasa_id == sasahyou->size - 1) {
sasahyou->size--;
sasahyou->database = reallocarray(sasahyou->database, sasahyou->size, sizeof(Sasa));
} else {
sasahyou->hole_cnt++;
sasahyou->holes = reallocarray(sasahyou->holes, sasahyou->hole_cnt, sizeof(Sasa *));
sasahyou->holes[sasahyou->hole_cnt - 1] = current_sasa;
}
sasahyou->modified_ts = time(NULL);
return 0;
}
int sasa_upd(Sasahyou *sasahyou, uint64_t sasa_id, const char *path) {
if (sasa_id == HOLE_ID || sasa_id >= sasahyou->size || path == NULL) {
return 1;
}
Sasa *current_sasa = sasahyou->database + sasa_id;
if (current_sasa->id == HOLE_ID) {
return 1;
}
current_sasa->path = realloc(current_sasa->path, strlen(path) + 1);
strcpy(current_sasa->path, path);
sasahyou->modified_ts = time(NULL);
return 0;
}
+208
View File
@@ -0,0 +1,208 @@
#include <stdint.h>
#include <malloc.h>
#include <string.h>
#include <time.h>
#include "core_func.h"
const Kazari HOLE_KAZARI = {HOLE_ID, HOLE_ID, 0};
// Shoppyou file signature: 七夕飾表
static const uint16_t SHOPPYOU_SIG[4] = {L'', L'', L'', L''};
int shoppyou_init(Shoppyou *shoppyou) {
shoppyou->created_ts = time(NULL);
shoppyou->modified_ts = shoppyou->created_ts;
shoppyou->size = 0;
shoppyou->database = NULL;
shoppyou->hole_cnt = 0;
shoppyou->holes = NULL;
shoppyou->file = NULL;
return 0;
}
int shoppyou_free(Shoppyou *shoppyou) {
shoppyou->created_ts = 0;
shoppyou->modified_ts = 0;
shoppyou->size = 0;
shoppyou->hole_cnt = 0;
free(shoppyou->database);
shoppyou->database = NULL;
free(shoppyou->holes);
shoppyou->holes = NULL;
if (shoppyou->file != NULL) {
fclose(shoppyou->file);
shoppyou->file = NULL;
}
return 0;
}
int shoppyou_load(Shoppyou *shoppyou) {
if (shoppyou->file == NULL ||
(shoppyou->file = freopen(NULL, "rb", shoppyou->file)) == NULL) {
return 1;
}
Shoppyou temp;
shoppyou_init(&temp);
temp.file = shoppyou->file;
uint16_t signature[4];
if (fread(signature, 2, 4, temp.file) != 4 ||
memcmp(signature, SHOPPYOU_SIG, 8) != 0 ||
fread(&temp.created_ts, 8, 1, temp.file) != 1 ||
fread(&temp.modified_ts, 8, 1, temp.file) != 1 ||
fread(&temp.size, 8, 1, temp.file) != 1) {
return 1;
}
temp.database = calloc(temp.size, sizeof(Kazari));
Kazari *current_kazari = temp.database;
for (uint64_t i = 0; i < temp.size; i++, current_kazari++) {
if (fread(&current_kazari->created_ts, 8, 1, temp.file) != 1 ||
fread(&current_kazari->sasa_id, 8, 1, temp.file) != 1 ||
fread(&current_kazari->tanzaku_id, 8, 1, temp.file) != 1) {
temp.file = NULL;
shoppyou_free(&temp);
return 1;
}
}
if (fflush(temp.file) == 0) {
shoppyou->file = NULL;
shoppyou_free(shoppyou);
*shoppyou = temp;
return 0;
}
temp.file = NULL;
shoppyou_free(&temp);
return 1;
}
int shoppyou_save(Shoppyou *shoppyou) {
if (shoppyou->file == NULL ||
(shoppyou->file = freopen(NULL, "wb", shoppyou->file)) == NULL ||
fwrite(SHOPPYOU_SIG, 2, 4, shoppyou->file) != 4 ||
fwrite(&shoppyou->created_ts, 8, 1, shoppyou->file) != 1 ||
fwrite(&shoppyou->modified_ts, 8, 1, shoppyou->file) != 1) {
return 1;
}
uint64_t size = shoppyou->size - shoppyou->hole_cnt;
if (fwrite(&size, 8, 1, shoppyou->file) != 1 ||
fflush(shoppyou->file) != 0) {
return 1;
}
Kazari *current_kazari = shoppyou->database;
for (uint64_t i = 0; i < shoppyou->size; i++, current_kazari++) {
if (shoppyou->database[i].sasa_id != HOLE_ID && shoppyou->database[i].tanzaku_id != HOLE_ID) {
if (fwrite(&current_kazari->created_ts, 8, 1, shoppyou->file) != 1 ||
fwrite(&current_kazari->sasa_id, 8, 1, shoppyou->file) != 1 ||
fwrite(&current_kazari->tanzaku_id, 8, 1, shoppyou->file) != 1) {
return 1;
}
}
}
return fflush(shoppyou->file);
}
int shoppyou_open(Shoppyou *shoppyou, const char *path) {
if (path == NULL) {
return 1;
}
if (shoppyou->file == NULL && (shoppyou->file = fopen(path, "rb")) == NULL ||
shoppyou->file != NULL && (shoppyou->file = freopen(path, "rb", shoppyou->file)) == NULL) {
return 1;
}
return shoppyou_load(shoppyou);
}
int shoppyou_dump(Shoppyou *shoppyou, const char *path) {
if (path == NULL) {
return 1;
}
if (shoppyou->file == NULL && (shoppyou->file = fopen(path, "wb")) == NULL ||
shoppyou->file != NULL && (shoppyou->file = freopen(path, "wb", shoppyou->file)) == NULL) {
return 1;
}
return shoppyou_save(shoppyou);
}
int kazari_add(Shoppyou *shoppyou, uint64_t sasa_id, uint64_t tanzaku_id) {
if (sasa_id == HOLE_ID || tanzaku_id == HOLE_ID || shoppyou->size == -1 && shoppyou->hole_cnt == 0) {
return 1;
}
Kazari newbie;
newbie.created_ts = time(NULL);
newbie.sasa_id = sasa_id;
newbie.tanzaku_id = tanzaku_id;
if (shoppyou->hole_cnt > 0) {
shoppyou->hole_cnt--;
**(shoppyou->holes + shoppyou->hole_cnt) = newbie;
shoppyou->holes = reallocarray(shoppyou->holes, shoppyou->hole_cnt, sizeof(Kazari *));
} else {
shoppyou->size++;
shoppyou->database = reallocarray(shoppyou->database, shoppyou->size, sizeof(Kazari));
shoppyou->database[shoppyou->size - 1] = newbie;
}
shoppyou->modified_ts = newbie.created_ts;
return 0;
}
int kazari_rem(Shoppyou *shoppyou, uint64_t sasa_id, uint64_t tanzaku_id) {
if (sasa_id == HOLE_ID || tanzaku_id == HOLE_ID) {
return 1;
}
Kazari *current_kazari = shoppyou->database;
for (uint64_t i = 0; i < shoppyou->size; i++, current_kazari++) {
if (current_kazari->sasa_id == sasa_id && current_kazari->tanzaku_id == tanzaku_id) {
current_kazari->sasa_id = HOLE_ID;
current_kazari->tanzaku_id = HOLE_ID;
shoppyou->hole_cnt++;
shoppyou->holes = reallocarray(shoppyou->holes, shoppyou->hole_cnt, sizeof(Kazari *));
shoppyou->holes[shoppyou->hole_cnt - 1] = current_kazari;
shoppyou->modified_ts = time(NULL);
break;
}
}
return 0;
}
int kazari_rem_by_sasa(Shoppyou *shoppyou, uint64_t sasa_id) {
if (sasa_id == HOLE_ID) {
return 1;
}
Kazari *current_kazari = shoppyou->database;
_Bool changed = 0;
for (uint64_t i = 0; i < shoppyou->size; i++, current_kazari++) {
if (current_kazari->sasa_id == sasa_id) {
current_kazari->sasa_id = HOLE_ID;
current_kazari->tanzaku_id = HOLE_ID;
shoppyou->hole_cnt++;
shoppyou->holes = reallocarray(shoppyou->holes, shoppyou->hole_cnt, sizeof(Kazari *));
shoppyou->holes[shoppyou->hole_cnt - 1] = current_kazari;
changed = 1;
}
}
if (changed) {
shoppyou->modified_ts = time(NULL);
}
return 0;
}
int kazari_rem_by_tanzaku(Shoppyou *shoppyou, uint64_t tanzaku_id) {
if (tanzaku_id == HOLE_ID) {
return 1;
}
Kazari *current_kazari = shoppyou->database;
_Bool changed = 0;
for (uint64_t i = 0; i < shoppyou->size; i++, current_kazari++) {
if (current_kazari->tanzaku_id == tanzaku_id) {
current_kazari->sasa_id = HOLE_ID;
current_kazari->tanzaku_id = HOLE_ID;
shoppyou->hole_cnt++;
shoppyou->holes = reallocarray(shoppyou->holes, shoppyou->hole_cnt, sizeof(Kazari *));
shoppyou->holes[shoppyou->hole_cnt - 1] = current_kazari;
changed = 1;
}
}
if (changed) {
shoppyou->modified_ts = time(NULL);
}
return 0;
}
+199
View File
@@ -0,0 +1,199 @@
#include <malloc.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include "../core/core_func.h"
#include "../../include/tanabata.h"
int tanabata_init(Tanabata *tanabata) {
if (sasahyou_init(&tanabata->sasahyou) != 0 ||
sappyou_init(&tanabata->sappyou) != 0 ||
shoppyou_init(&tanabata->shoppyou) != 0) {
return 1;
}
tanabata->sappyou.size = 1;
tanabata->sappyou.database = malloc(sizeof(Tanzaku));
tanabata->sappyou.database->id = 0;
tanabata->sappyou.database->created_ts = tanabata->sappyou.created_ts;
tanabata->sappyou.database->modified_ts = tanabata->sappyou.created_ts;
tanabata->sappyou.database->name = malloc(9);
tanabata->sappyou.database->description = malloc(30);
strcpy(tanabata->sappyou.database->name, "FAVORITE");
strcpy(tanabata->sappyou.database->description, "Special tanzaku for favorites");
tanabata->sasahyou_mod = 0;
tanabata->sappyou_mod = 0;
tanabata->shoppyou_mod = 0;
return 0;
}
int tanabata_free(Tanabata *tanabata) {
if (sasahyou_free(&tanabata->sasahyou) != 0 ||
sappyou_free(&tanabata->sappyou) != 0 ||
shoppyou_free(&tanabata->shoppyou) != 0) {
return 1;
}
tanabata->sasahyou_mod = 0;
tanabata->sappyou_mod = 0;
tanabata->shoppyou_mod = 0;
return 0;
}
int tanabata_weed(Tanabata *tanabata) {
uint64_t hole_cnt = 0, new_id;
Kazari *current_kazari;
Sasa *current_sasa = tanabata->sasahyou.database;
for (uint64_t i = 0; i < tanabata->sasahyou.size; i++, current_sasa++) {
if (current_sasa->id != HOLE_ID) {
if (hole_cnt > 0) {
new_id = current_sasa->id - hole_cnt;
for (current_kazari = tanabata->shoppyou.database + tanabata->shoppyou.size - 1;
current_kazari >= tanabata->shoppyou.database; current_kazari--) {
if (current_kazari->sasa_id == current_sasa->id) {
current_kazari->sasa_id = new_id;
}
}
current_sasa->id = new_id;
*(current_sasa - hole_cnt) = *current_sasa;
}
} else {
kazari_rem_by_sasa(&tanabata->shoppyou, current_sasa->id);
hole_cnt++;
}
}
if (hole_cnt > 0) {
tanabata->sasahyou.size -= hole_cnt;
tanabata->sasahyou.hole_cnt = 0;
free(tanabata->sasahyou.holes);
tanabata->sasahyou.holes = NULL;
tanabata->sasahyou.database = reallocarray(tanabata->sasahyou.database, tanabata->sasahyou.size,
sizeof(Sasa));
tanabata->sasahyou.modified_ts = time(NULL);
}
hole_cnt = 0;
Tanzaku *current_tanzaku = tanabata->sappyou.database;
for (uint64_t i = 0; i < tanabata->sappyou.size; i++, current_tanzaku++) {
if (current_tanzaku->id != HOLE_ID) {
if (hole_cnt > 0) {
new_id = current_tanzaku->id - hole_cnt;
for (current_kazari = tanabata->shoppyou.database + tanabata->shoppyou.size - 1;
current_kazari >= tanabata->shoppyou.database; current_kazari--) {
if (current_kazari->tanzaku_id == current_tanzaku->id) {
current_kazari->tanzaku_id = new_id;
}
}
current_tanzaku->id = new_id;
*(current_tanzaku - hole_cnt) = *current_tanzaku;
} else {
hole_cnt++;
}
}
}
if (hole_cnt > 0) {
tanabata->sappyou.size -= tanabata->sappyou.hole_cnt;
tanabata->sappyou.hole_cnt = 0;
free(tanabata->sappyou.holes);
tanabata->sappyou.holes = NULL;
tanabata->sappyou.database = reallocarray(tanabata->sappyou.database, tanabata->sappyou.size,
sizeof(Tanzaku));
tanabata->sappyou.modified_ts = time(NULL);
}
hole_cnt = 0;
current_kazari = tanabata->shoppyou.database;
for (uint64_t i = 0; i < tanabata->shoppyou.size; i++, current_kazari++) {
if (current_kazari->sasa_id != HOLE_ID && current_kazari->tanzaku_id != HOLE_ID &&
current_kazari->sasa_id < tanabata->sasahyou.size &&
current_kazari->tanzaku_id < tanabata->sappyou.size) {
if (hole_cnt > 0) {
*(current_kazari - hole_cnt) = *current_kazari;
}
} else {
hole_cnt++;
}
}
if (hole_cnt > 0) {
tanabata->shoppyou.size -= tanabata->shoppyou.hole_cnt;
tanabata->shoppyou.hole_cnt = 0;
free(tanabata->shoppyou.holes);
tanabata->shoppyou.holes = NULL;
tanabata->shoppyou.database = reallocarray(tanabata->shoppyou.database, tanabata->shoppyou.size,
sizeof(Kazari));
tanabata->shoppyou.modified_ts = time(NULL);
}
return 0;
}
int tanabata_load(Tanabata *tanabata) {
if (sasahyou_load(&tanabata->sasahyou) != 0 ||
sappyou_load(&tanabata->sappyou) != 0 ||
shoppyou_load(&tanabata->shoppyou) != 0) {
return 1;
}
tanabata->sasahyou_mod = tanabata->sasahyou.modified_ts;
tanabata->sappyou_mod = tanabata->sappyou.modified_ts;
tanabata->shoppyou_mod = tanabata->shoppyou.modified_ts;
return 0;
}
int tanabata_save(Tanabata *tanabata) {
if (tanabata->sasahyou_mod != tanabata->sasahyou.modified_ts && sasahyou_save(&tanabata->sasahyou) != 0 ||
tanabata->sappyou_mod != tanabata->sappyou.modified_ts && sappyou_save(&tanabata->sappyou) != 0 ||
tanabata->shoppyou_mod != tanabata->shoppyou.modified_ts && shoppyou_save(&tanabata->shoppyou) != 0) {
return 1;
}
tanabata->sasahyou_mod = tanabata->sasahyou.modified_ts;
tanabata->sappyou_mod = tanabata->sappyou.modified_ts;
tanabata->shoppyou_mod = tanabata->shoppyou.modified_ts;
return 0;
}
int tanabata_open(Tanabata *tanabata, const char *path) {
if (path == NULL) {
return 1;
}
struct stat st;
if (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) {
return 1;
}
size_t pathlen = strlen(path);
char *file_path = malloc(pathlen + 10);
strcpy(file_path, path);
if (sasahyou_open(&tanabata->sasahyou, strcpy(file_path + pathlen, "/sasahyou") - pathlen) != 0 ||
sappyou_open(&tanabata->sappyou, strcpy(file_path + pathlen, "/sappyou") - pathlen) != 0 ||
shoppyou_open(&tanabata->shoppyou, strcpy(file_path + pathlen, "/shoppyou") - pathlen) != 0) {
free(file_path);
return 1;
}
free(file_path);
tanabata->sasahyou_mod = tanabata->sasahyou.modified_ts;
tanabata->sappyou_mod = tanabata->sappyou.modified_ts;
tanabata->shoppyou_mod = tanabata->shoppyou.modified_ts;
return 0;
}
int tanabata_dump(Tanabata *tanabata, const char *path) {
if (path == NULL) {
return 1;
}
struct stat st;
if (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) {
return 1;
}
size_t pathlen = strlen(path);
char *file_path = malloc(pathlen + 10);
strcpy(file_path, path);
if (tanabata->sasahyou_mod != tanabata->sasahyou.modified_ts &&
sasahyou_dump(&tanabata->sasahyou, strcpy(file_path + pathlen, "/sasahyou") - pathlen) != 0 ||
tanabata->sappyou_mod != tanabata->sappyou.modified_ts &&
sappyou_dump(&tanabata->sappyou, strcpy(file_path + pathlen, "/sappyou") - pathlen) != 0 ||
tanabata->shoppyou_mod != tanabata->shoppyou.modified_ts &&
shoppyou_dump(&tanabata->shoppyou, strcpy(file_path + pathlen, "/shoppyou") - pathlen) != 0) {
free(file_path);
return 1;
}
free(file_path);
tanabata->sasahyou_mod = tanabata->sasahyou.modified_ts;
tanabata->sappyou_mod = tanabata->sappyou.modified_ts;
tanabata->shoppyou_mod = tanabata->shoppyou.modified_ts;
return 0;
}
+68
View File
@@ -0,0 +1,68 @@
#include <malloc.h>
#include "../core/core_func.h"
#include "../../include/tanabata.h"
int tanabata_kazari_add(Tanabata *tanabata, uint64_t sasa_id, uint64_t tanzaku_id) {
if (sasa_id >= tanabata->sasahyou.size || tanzaku_id >= tanabata->sappyou.size ||
tanabata->shoppyou.size == -1 && tanabata->shoppyou.hole_cnt == 0) {
return 1;
}
if (tanabata->shoppyou.size - tanabata->shoppyou.hole_cnt > 0) {
Kazari *current_kazari = tanabata->shoppyou.database + tanabata->shoppyou.size - 1;
for (; current_kazari >= tanabata->shoppyou.database; current_kazari--) {
if (current_kazari->sasa_id == sasa_id && current_kazari->tanzaku_id == tanzaku_id) {
return 1;
}
}
}
return kazari_add(&tanabata->shoppyou, sasa_id, tanzaku_id);
}
int tanabata_kazari_rem(Tanabata *tanabata, uint64_t sasa_id, uint64_t tanzaku_id) {
return kazari_rem(&tanabata->shoppyou, sasa_id, tanzaku_id);
}
Tanzaku *tanabata_tanzaku_get_by_sasa(Tanabata *tanabata, uint64_t sasa_id) {
if (sasa_id == HOLE_ID || sasa_id >= tanabata->sasahyou.size ||
tanabata->shoppyou.size - tanabata->shoppyou.hole_cnt == 0) {
return NULL;
}
Tanzaku *tanzaku_list = NULL;
uint64_t tanzaku_count = 0;
Tanzaku temp;
Kazari *current_kazari = tanabata->shoppyou.database;
for (uint64_t i = 0; i < tanabata->shoppyou.size; i++, current_kazari++) {
if (current_kazari->sasa_id == sasa_id &&
(temp = tanabata_tanzaku_get(tanabata, current_kazari->tanzaku_id)).id != HOLE_ID) {
tanzaku_count++;
tanzaku_list = reallocarray(tanzaku_list, tanzaku_count, sizeof(Tanzaku));
tanzaku_list[tanzaku_count - 1] = temp;
}
}
tanzaku_list = reallocarray(tanzaku_list, tanzaku_count + 1, sizeof(Tanzaku));
tanzaku_list[tanzaku_count] = HOLE_TANZAKU;
return tanzaku_list;
}
Sasa *tanabata_sasa_get_by_tanzaku(Tanabata *tanabata, uint64_t tanzaku_id) {
if (tanzaku_id == HOLE_ID || tanzaku_id >= tanabata->sappyou.size ||
tanabata->shoppyou.size - tanabata->shoppyou.hole_cnt == 0) {
return NULL;
}
Sasa *sasa_list = NULL;
uint64_t sasa_count = 0;
Sasa temp;
Kazari *current_kazari = tanabata->shoppyou.database;
for (uint64_t i = 0; i < tanabata->shoppyou.size; i++, current_kazari++) {
if (current_kazari->tanzaku_id == tanzaku_id &&
(temp = tanabata_sasa_get(tanabata, current_kazari->sasa_id)).id != HOLE_ID) {
sasa_count++;
sasa_list = reallocarray(sasa_list, sasa_count, sizeof(Sasa));
sasa_list[sasa_count - 1] = temp;
}
}
sasa_list = reallocarray(sasa_list, sasa_count + 1, sizeof(Sasa));
sasa_list[sasa_count] = HOLE_SASA;
return sasa_list;
}
+25
View File
@@ -0,0 +1,25 @@
#include "../core/core_func.h"
#include "../../include/tanabata.h"
Sasa tanabata_sasa_add(Tanabata *tanabata, const char *path) {
return sasa_add(&tanabata->sasahyou, path);
}
int tanabata_sasa_rem(Tanabata *tanabata, uint64_t sasa_id) {
if (sasa_rem(&tanabata->sasahyou, sasa_id) == 0 &&
kazari_rem_by_sasa(&tanabata->shoppyou, sasa_id) == 0) {
return 0;
}
return 1;
}
int tanabata_sasa_upd(Tanabata *tanabata, uint64_t sasa_id, const char *path) {
return sasa_upd(&tanabata->sasahyou, sasa_id, path);
}
Sasa tanabata_sasa_get(Tanabata *tanabata, uint64_t sasa_id) {
if (sasa_id == HOLE_ID || sasa_id >= tanabata->sasahyou.size) {
return HOLE_SASA;
}
return tanabata->sasahyou.database[sasa_id];
}
+25
View File
@@ -0,0 +1,25 @@
#include "../core/core_func.h"
#include "../../include/tanabata.h"
Tanzaku tanabata_tanzaku_add(Tanabata *tanabata, const char *name, const char *description) {
return tanzaku_add(&tanabata->sappyou, name, description);
}
int tanabata_tanzaku_rem(Tanabata *tanabata, uint64_t tanzaku_id) {
if (tanzaku_rem(&tanabata->sappyou, tanzaku_id) == 0 &&
kazari_rem_by_tanzaku(&tanabata->shoppyou, tanzaku_id) == 0) {
return 0;
}
return 1;
}
int tanabata_tanzaku_upd(Tanabata *tanabata, uint64_t tanzaku_id, const char *name, const char *description) {
return tanzaku_upd(&tanabata->sappyou, tanzaku_id, name, description);
}
Tanzaku tanabata_tanzaku_get(Tanabata *tanabata, uint64_t tanzaku_id) {
if (tanzaku_id == HOLE_ID || tanzaku_id >= tanabata->sappyou.size) {
return HOLE_TANZAKU;
}
return tanabata->sappyou.database[tanzaku_id];
}
+74
View File
@@ -0,0 +1,74 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../../include/tdbms-client.h"
int main(int argc, char **argv) {
if (argc == 1 || strcmp(argv[1], "-h") == 0) {
printf("Tanabata Database Management client\n\n"
"Usage\n"
" tdb [DB_NAME [REQUEST_CODE [REQUEST_BODY]]]\n\n"
"Request codes:\n"
" 0\tDB stats\n"
" 3\tDB init\n"
" 2\tDB load\n"
" 4\tDB save\n"
" 6\tDB edit\n"
" 1\tDB remove soft\n"
" 5\tDB remove hard\n"
" 7\tDB weed\n"
" 16\tSasa get\n"
" 40\tSasa get by tanzaku\n"
" 18\tSasa add\n"
" 20\tSasa update\n"
" 17\tSasa remove\n"
" 32\tTanzaku get\n"
" 24\tTanzaku get by sasa\n"
" 34\tTanzaku add\n"
" 36\tTanzaku update\n"
" 33\tTanzaku remove\n"
" 8\tKazari get\n"
" 10\tKazari add\n"
" 26\tKazari add single sasa to multiple tanzaku\n"
" 42\tKazari add single tanzaku to multiple sasa\n"
" 9\tKazari remove\n"
" 25\tKazari remove single sasa to multiple tanzaku\n"
" 41\tKazari remove single tanzaku to multiple sasa\n");
return 0;
}
char *db_name, request_code, *request_body;
if (argc < 4) {
request_body = "";
} else {
request_body = argv[3];
}
if (argc < 3) {
request_code = 0;
} else {
char *endptr;
request_code = (char) strtol(argv[2], &endptr, 0);
if (*endptr != 0) {
fprintf(stderr, "FATAL: invalid request code '%s'\n", argv[2]);
return 1;
}
}
if (argc < 2) {
db_name = "";
} else {
db_name = argv[1];
}
int socket_fd = tdbms_connect("UNIX", "/tmp/tdbms.sock");
if (socket_fd < 0) {
fprintf(stderr, "FATAL: failed to connect to TDBMS server\n");
return 1;
}
char *response = tdb_query(socket_fd, db_name, request_code, request_body);
if (response == NULL) {
fprintf(stderr, "FATAL: failed to execute request\n");
return 1;
}
printf("%s\n", response);
tdbms_close(socket_fd);
return 0;
}
+90
View File
@@ -0,0 +1,90 @@
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include "../../include/tdbms-client.h"
int tdbms_connect(const char *domain, const char *addr) {
int socket_fd;
struct sockaddr_un sockaddr;
int domain_code;
if (strcmp(domain, "UNIX") == 0) {
domain_code = AF_UNIX;
} else {
fprintf(stderr, "ERROR: unexpected socket domain '%s'\n", domain);
return -1;
}
if (strlen(addr) > sizeof(sockaddr.sun_path) - 1) {
fprintf(stderr, "ERROR: too long socket address\n");
return -1;
}
socket_fd = socket(domain_code, SOCK_STREAM, 0);
if (socket_fd < 0) {
fprintf(stderr, "ERROR: failed to initialize socket\n");
return -1;
}
bzero(&sockaddr, sizeof(sockaddr));
sockaddr.sun_family = domain_code;
strcpy(sockaddr.sun_path, addr);
if (connect(socket_fd, (const struct sockaddr *) &sockaddr, sizeof(sockaddr)) < 0) {
fprintf(stderr, "ERROR: failed to connect the socket\n");
return -1;
}
return socket_fd;
}
int tdbms_close(int socket_fd) {
return close(socket_fd);
}
char *tdb_query(int socket_fd, const char *db_name, char request_code, const char *request_body) {
if (socket_fd < 0 || db_name == NULL || request_body == NULL) {
return NULL;
}
size_t req_size = 1 + strlen(db_name) + 1 + strlen(request_body) + 1, resp_size;
ssize_t nread, nwrite;
char *request = malloc(req_size);
char *buffer = request;
*buffer = request_code;
buffer++;
strcpy(buffer, db_name);
buffer += strlen(db_name) + 1;
strcpy(buffer, request_body);
for (buffer = request; (nwrite = write(socket_fd, buffer, req_size)) > 0;) {
buffer += nwrite;
req_size -= nwrite;
if (req_size == 0) {
nwrite = write(socket_fd, "\4", 1);
break;
}
}
free(request);
if (nwrite <= 0) {
fprintf(stderr, "ERROR: failed to send request to server\n");
return NULL;
}
char *response = malloc(BUFSIZ);
resp_size = BUFSIZ;
buffer = malloc(BUFSIZ);
for (off_t offset = 0; (nread = read(socket_fd, buffer, BUFSIZ)) > 0;) {
if (offset + nread > resp_size) {
resp_size += BUFSIZ;
response = realloc(response, resp_size);
}
memcpy(response + offset, buffer, nread);
offset += nread;
if (response[offset - 1] == EOT) {
break;
}
}
free(buffer);
if (nread < 0) {
fprintf(stderr, "ERROR: failed to get server response\n");
free(response);
return NULL;
}
return response;
}

Some files were not shown because too many files have changed in this diff Show More