├── .gitattributes ├── .gitignore ├── README.md ├── build.zig ├── build.zig.zon ├── lib ├── sqlite3.c ├── sqlite3.h └── sqlite3ext.h ├── res ├── resource.rc └── webmaker2000.ico ├── screenshot.webp ├── src ├── Adwaita.zig ├── blobstore.zig ├── constants.zig ├── core.zig ├── db_schema.sql ├── djot.license ├── djot.lua ├── djot.zig ├── favicon.png ├── fonts │ ├── NotoSans-Bold.ttf │ ├── NotoSans-Regular.ttf │ └── license.txt ├── history.zig ├── html.zig ├── main.zig ├── maths.zig ├── queries.zig ├── server.zig ├── sitefs.zig ├── sql.zig ├── theme.zig └── util.zig └── xdg └── .local └── share ├── applications └── webmaker2000.desktop ├── icons └── hicolor │ └── scalable │ ├── apps │ └── webmaker2000.svg │ └── mimetypes │ └── application-webmaker2000.svg └── mime └── packages └── application-webmaker2000.xml /.gitattributes: -------------------------------------------------------------------------------- 1 | lib/* linguist-vendored 2 | src/djot.lua linguist-vendored 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.zig-cache 2 | /zig-out 3 | 4 | # dummy site: 5 | /Site1 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # What 3 | 4 | WebMaker2000 is a (WIP) cross-platform static site generator with an 5 | unapologetically spartan GUI: 6 | 7 | ![](screenshot.webp) 8 | 9 | More up-to-date screenshots can be found 10 | [here](https://github.com/nhanb/webmaker2000/issues/1). 11 | 12 | The implementation is an experiment in [rubbing sqlite][1] on a desktop GUI: 13 | 14 | Both data and GUI state are stored in an sqlite3 database, all of which are 15 | queried every frame. All GUI actions trigger changes to the underlying db 16 | instead of mutating any in-memory representation. This sounds expensive, but: 17 | 18 | - dvui (the GUI library) only redraws when there is user interaction. 19 | - sqlite has an in-memory page cache out of the box, so _read_ queries do not 20 | hit the disk every redraw. 21 | - _write_ queries only happen when the user actually changes something, and 22 | even when they do, sqlite is [fast][3], especially now that SSDs are the norm. 23 | 24 | What we gain is a massively simplified unidirectional state management system, 25 | and we get **autosave** for free on every single action. We also get all of the 26 | benefits of using sqlite as an application file format, chief among them being 27 | **persistent undo/redo**, but also atomic writes, easy atomic schema changes, 28 | powerful data modelling & querying capabilities. 29 | 30 | Remaining puzzle to solve: background processing for tasks that take longer than 31 | our per-frame budget: 32 | 33 | - handling mid-operation crashes might be tricky? 34 | - how would it interact with the undo thing? 35 | 36 | MVP checklist: 37 | 38 | - [x] Persistent undo/redo: Basically adapted [sqlite's guide][undo] to 39 | [emacs' undo style][emacs], which avoids accidentally losing data. 40 | - [x] Asset upload 41 | - [ ] User-configurable deploy command (e.g. shelling out to rclone) 42 | - [x] Preview server 43 | - [ ] User-customizable template system 44 | - [ ] RSS/Atom feed 45 | - [ ] OpenGraph tags 46 | 47 | # Compile 48 | 49 | Dependencies: 50 | 51 | - Build time: zig 0.14.0 52 | - Runtime: libc 53 | 54 | Statically compiled deps that can optionally be linked dynamically: 55 | 56 | - sdl3 57 | - freetype2 58 | - sqlite3 59 | - lua (for djot.lua, which is vendored here) 60 | 61 | 62 | ```sh 63 | zig build run 64 | 65 | # or, to watch: 66 | find src | entr -rc zig build run 67 | 68 | # optionally, to dynamically link to system libraries: 69 | zig build -fsys=sdl3 -fsys=freetype -fsys=sqlite3 -fsys=lua 70 | 71 | # conversely, to compile a super compatible executable that will Just Work on 72 | # any GNU/Linux distro that's not older than Debian 10 "Buster": 73 | zig build -Dtarget=x86_64-linux-gnu.2.28 74 | ``` 75 | 76 | # Linux desktop integration 77 | 78 | Copy files from `./xdg` into your $HOME, or symlink using [stow][stow]: 79 | `stow --no-folding --verbose=1 -t $HOME xdg` 80 | 81 | [1]: https://www.hytradboi.com/2022/building-data-centric-apps-with-a-reactive-relational-database 82 | [3]: https://www.sqlite.org/faq.html#q19 83 | [stow]: https://www.gnu.org/software/stow/ 84 | [undo]: https://www.sqlite.org/undoredo.html 85 | [emacs]: https://www.gnu.org/software/emacs/manual/html_node/emacs/Undo.html 86 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) !void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{}); 6 | 7 | const exe = b.addExecutable(.{ 8 | .name = "wm2k", 9 | .root_source_file = b.path("src/main.zig"), 10 | .target = target, 11 | .optimize = optimize, 12 | }); 13 | 14 | const dvui_dep = b.dependency("dvui", .{ 15 | .target = target, 16 | .optimize = optimize, 17 | .backend = .sdl3, 18 | }); 19 | exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); 20 | 21 | // zqlite 22 | const zqlite = b.dependency("zqlite", .{ 23 | .target = target, 24 | .optimize = optimize, 25 | }); 26 | exe.linkLibC(); 27 | if (b.systemIntegrationOption("sqlite3", .{})) { 28 | exe.linkSystemLibrary("sqlite3"); 29 | } else { 30 | exe.addCSourceFile(.{ 31 | .file = b.path("lib/sqlite3.c"), 32 | .flags = &[_][]const u8{ 33 | "-DSQLITE_DQS=0", 34 | "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", 35 | "-DSQLITE_USE_ALLOCA=1", 36 | "-DSQLITE_THREADSAFE=1", 37 | "-DSQLITE_TEMP_STORE=3", 38 | "-DSQLITE_ENABLE_API_ARMOR=1", 39 | "-DSQLITE_ENABLE_UNLOCK_NOTIFY", 40 | "-DSQLITE_ENABLE_UPDATE_DELETE_LIMIT=1", 41 | "-DSQLITE_DEFAULT_FILE_PERMISSIONS=0600", 42 | "-DSQLITE_OMIT_DECLTYPE=1", 43 | "-DSQLITE_OMIT_DEPRECATED=1", 44 | "-DSQLITE_OMIT_LOAD_EXTENSION=1", 45 | "-DSQLITE_OMIT_PROGRESS_CALLBACK=1", 46 | "-DSQLITE_OMIT_SHARED_CACHE", 47 | "-DSQLITE_OMIT_TRACE=1", 48 | "-DSQLITE_OMIT_UTF16=1", 49 | "-DHAVE_USLEEP=0", 50 | }, 51 | }); 52 | } 53 | exe.root_module.addImport("zqlite", zqlite.module("zqlite")); 54 | 55 | // ziglua is now called lua_wrapper for some reason 56 | const use_system_lua = b.systemIntegrationOption("lua", .{}); 57 | if (use_system_lua) { 58 | exe.linkSystemLibrary("lua"); 59 | } 60 | const lua_wrapper = b.dependency("lua_wrapper", .{ 61 | .target = target, 62 | .optimize = optimize, 63 | .shared = use_system_lua, 64 | }); 65 | exe.root_module.addImport("lua_wrapper", lua_wrapper.module("lua_wrapper")); 66 | 67 | exe.addWin32ResourceFile(.{ .file = b.path("res/resource.rc") }); 68 | 69 | const compile_step = b.step("compile-wm2k", "Compile wm2k"); 70 | compile_step.dependOn(&b.addInstallArtifact(exe, .{}).step); 71 | b.getInstallStep().dependOn(compile_step); 72 | 73 | const run_cmd = b.addRunArtifact(exe); 74 | run_cmd.step.dependOn(compile_step); 75 | 76 | const run_step = b.step("run", "Run wm2k"); 77 | run_step.dependOn(&run_cmd.step); 78 | } 79 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .webmaker2000, 3 | .version = "0.0.1", 4 | .fingerprint = 0xbbb760496aeb26ae, 5 | .dependencies = .{ 6 | .dvui = .{ 7 | .url = "git+https://github.com/david-vanderson/dvui?ref=main#49c99ddea7a5da5490937ef4b8dfd797c5f2900f", 8 | .hash = "dvui-0.2.0-AQFJmeqczABmEvNRzhffd4ozCmWHR2O8cJMOGxqdQTjs", 9 | }, 10 | .zqlite = .{ 11 | .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#d146898482a4c4bdcba487496f67a24afa12894d", 12 | .hash = "zqlite-0.0.0-RWLaYxp7lQBh-o0STqzS1uZZb0tLTCGBDgzpG_dGjd2J", 13 | }, 14 | .lua_wrapper = .{ 15 | .url = "git+https://github.com/natecraddock/ziglua?ref=main#7bfb3c2b87220cdc89ef01cc99a200dad7a28e50", 16 | .hash = "lua_wrapper-0.1.0-OyMC27fOBAAU3E2ueB-EWGSgsuCFQZL83pT0nQJ1ufOI", 17 | }, 18 | }, 19 | .paths = .{""}, 20 | } 21 | -------------------------------------------------------------------------------- /lib/sqlite3ext.h: -------------------------------------------------------------------------------- 1 | /* 2 | ** 2006 June 7 3 | ** 4 | ** The author disclaims copyright to this source code. In place of 5 | ** a legal notice, here is a blessing: 6 | ** 7 | ** May you do good and not evil. 8 | ** May you find forgiveness for yourself and forgive others. 9 | ** May you share freely, never taking more than you give. 10 | ** 11 | ************************************************************************* 12 | ** This header file defines the SQLite interface for use by 13 | ** shared libraries that want to be imported as extensions into 14 | ** an SQLite instance. Shared libraries that intend to be loaded 15 | ** as extensions by SQLite should #include this file instead of 16 | ** sqlite3.h. 17 | */ 18 | #ifndef SQLITE3EXT_H 19 | #define SQLITE3EXT_H 20 | #include "sqlite3.h" 21 | 22 | /* 23 | ** The following structure holds pointers to all of the SQLite API 24 | ** routines. 25 | ** 26 | ** WARNING: In order to maintain backwards compatibility, add new 27 | ** interfaces to the end of this structure only. If you insert new 28 | ** interfaces in the middle of this structure, then older different 29 | ** versions of SQLite will not be able to load each other's shared 30 | ** libraries! 31 | */ 32 | struct sqlite3_api_routines { 33 | void * (*aggregate_context)(sqlite3_context*,int nBytes); 34 | int (*aggregate_count)(sqlite3_context*); 35 | int (*bind_blob)(sqlite3_stmt*,int,const void*,int n,void(*)(void*)); 36 | int (*bind_double)(sqlite3_stmt*,int,double); 37 | int (*bind_int)(sqlite3_stmt*,int,int); 38 | int (*bind_int64)(sqlite3_stmt*,int,sqlite_int64); 39 | int (*bind_null)(sqlite3_stmt*,int); 40 | int (*bind_parameter_count)(sqlite3_stmt*); 41 | int (*bind_parameter_index)(sqlite3_stmt*,const char*zName); 42 | const char * (*bind_parameter_name)(sqlite3_stmt*,int); 43 | int (*bind_text)(sqlite3_stmt*,int,const char*,int n,void(*)(void*)); 44 | int (*bind_text16)(sqlite3_stmt*,int,const void*,int,void(*)(void*)); 45 | int (*bind_value)(sqlite3_stmt*,int,const sqlite3_value*); 46 | int (*busy_handler)(sqlite3*,int(*)(void*,int),void*); 47 | int (*busy_timeout)(sqlite3*,int ms); 48 | int (*changes)(sqlite3*); 49 | int (*close)(sqlite3*); 50 | int (*collation_needed)(sqlite3*,void*,void(*)(void*,sqlite3*, 51 | int eTextRep,const char*)); 52 | int (*collation_needed16)(sqlite3*,void*,void(*)(void*,sqlite3*, 53 | int eTextRep,const void*)); 54 | const void * (*column_blob)(sqlite3_stmt*,int iCol); 55 | int (*column_bytes)(sqlite3_stmt*,int iCol); 56 | int (*column_bytes16)(sqlite3_stmt*,int iCol); 57 | int (*column_count)(sqlite3_stmt*pStmt); 58 | const char * (*column_database_name)(sqlite3_stmt*,int); 59 | const void * (*column_database_name16)(sqlite3_stmt*,int); 60 | const char * (*column_decltype)(sqlite3_stmt*,int i); 61 | const void * (*column_decltype16)(sqlite3_stmt*,int); 62 | double (*column_double)(sqlite3_stmt*,int iCol); 63 | int (*column_int)(sqlite3_stmt*,int iCol); 64 | sqlite_int64 (*column_int64)(sqlite3_stmt*,int iCol); 65 | const char * (*column_name)(sqlite3_stmt*,int); 66 | const void * (*column_name16)(sqlite3_stmt*,int); 67 | const char * (*column_origin_name)(sqlite3_stmt*,int); 68 | const void * (*column_origin_name16)(sqlite3_stmt*,int); 69 | const char * (*column_table_name)(sqlite3_stmt*,int); 70 | const void * (*column_table_name16)(sqlite3_stmt*,int); 71 | const unsigned char * (*column_text)(sqlite3_stmt*,int iCol); 72 | const void * (*column_text16)(sqlite3_stmt*,int iCol); 73 | int (*column_type)(sqlite3_stmt*,int iCol); 74 | sqlite3_value* (*column_value)(sqlite3_stmt*,int iCol); 75 | void * (*commit_hook)(sqlite3*,int(*)(void*),void*); 76 | int (*complete)(const char*sql); 77 | int (*complete16)(const void*sql); 78 | int (*create_collation)(sqlite3*,const char*,int,void*, 79 | int(*)(void*,int,const void*,int,const void*)); 80 | int (*create_collation16)(sqlite3*,const void*,int,void*, 81 | int(*)(void*,int,const void*,int,const void*)); 82 | int (*create_function)(sqlite3*,const char*,int,int,void*, 83 | void (*xFunc)(sqlite3_context*,int,sqlite3_value**), 84 | void (*xStep)(sqlite3_context*,int,sqlite3_value**), 85 | void (*xFinal)(sqlite3_context*)); 86 | int (*create_function16)(sqlite3*,const void*,int,int,void*, 87 | void (*xFunc)(sqlite3_context*,int,sqlite3_value**), 88 | void (*xStep)(sqlite3_context*,int,sqlite3_value**), 89 | void (*xFinal)(sqlite3_context*)); 90 | int (*create_module)(sqlite3*,const char*,const sqlite3_module*,void*); 91 | int (*data_count)(sqlite3_stmt*pStmt); 92 | sqlite3 * (*db_handle)(sqlite3_stmt*); 93 | int (*declare_vtab)(sqlite3*,const char*); 94 | int (*enable_shared_cache)(int); 95 | int (*errcode)(sqlite3*db); 96 | const char * (*errmsg)(sqlite3*); 97 | const void * (*errmsg16)(sqlite3*); 98 | int (*exec)(sqlite3*,const char*,sqlite3_callback,void*,char**); 99 | int (*expired)(sqlite3_stmt*); 100 | int (*finalize)(sqlite3_stmt*pStmt); 101 | void (*free)(void*); 102 | void (*free_table)(char**result); 103 | int (*get_autocommit)(sqlite3*); 104 | void * (*get_auxdata)(sqlite3_context*,int); 105 | int (*get_table)(sqlite3*,const char*,char***,int*,int*,char**); 106 | int (*global_recover)(void); 107 | void (*interruptx)(sqlite3*); 108 | sqlite_int64 (*last_insert_rowid)(sqlite3*); 109 | const char * (*libversion)(void); 110 | int (*libversion_number)(void); 111 | void *(*malloc)(int); 112 | char * (*mprintf)(const char*,...); 113 | int (*open)(const char*,sqlite3**); 114 | int (*open16)(const void*,sqlite3**); 115 | int (*prepare)(sqlite3*,const char*,int,sqlite3_stmt**,const char**); 116 | int (*prepare16)(sqlite3*,const void*,int,sqlite3_stmt**,const void**); 117 | void * (*profile)(sqlite3*,void(*)(void*,const char*,sqlite_uint64),void*); 118 | void (*progress_handler)(sqlite3*,int,int(*)(void*),void*); 119 | void *(*realloc)(void*,int); 120 | int (*reset)(sqlite3_stmt*pStmt); 121 | void (*result_blob)(sqlite3_context*,const void*,int,void(*)(void*)); 122 | void (*result_double)(sqlite3_context*,double); 123 | void (*result_error)(sqlite3_context*,const char*,int); 124 | void (*result_error16)(sqlite3_context*,const void*,int); 125 | void (*result_int)(sqlite3_context*,int); 126 | void (*result_int64)(sqlite3_context*,sqlite_int64); 127 | void (*result_null)(sqlite3_context*); 128 | void (*result_text)(sqlite3_context*,const char*,int,void(*)(void*)); 129 | void (*result_text16)(sqlite3_context*,const void*,int,void(*)(void*)); 130 | void (*result_text16be)(sqlite3_context*,const void*,int,void(*)(void*)); 131 | void (*result_text16le)(sqlite3_context*,const void*,int,void(*)(void*)); 132 | void (*result_value)(sqlite3_context*,sqlite3_value*); 133 | void * (*rollback_hook)(sqlite3*,void(*)(void*),void*); 134 | int (*set_authorizer)(sqlite3*,int(*)(void*,int,const char*,const char*, 135 | const char*,const char*),void*); 136 | void (*set_auxdata)(sqlite3_context*,int,void*,void (*)(void*)); 137 | char * (*xsnprintf)(int,char*,const char*,...); 138 | int (*step)(sqlite3_stmt*); 139 | int (*table_column_metadata)(sqlite3*,const char*,const char*,const char*, 140 | char const**,char const**,int*,int*,int*); 141 | void (*thread_cleanup)(void); 142 | int (*total_changes)(sqlite3*); 143 | void * (*trace)(sqlite3*,void(*xTrace)(void*,const char*),void*); 144 | int (*transfer_bindings)(sqlite3_stmt*,sqlite3_stmt*); 145 | void * (*update_hook)(sqlite3*,void(*)(void*,int ,char const*,char const*, 146 | sqlite_int64),void*); 147 | void * (*user_data)(sqlite3_context*); 148 | const void * (*value_blob)(sqlite3_value*); 149 | int (*value_bytes)(sqlite3_value*); 150 | int (*value_bytes16)(sqlite3_value*); 151 | double (*value_double)(sqlite3_value*); 152 | int (*value_int)(sqlite3_value*); 153 | sqlite_int64 (*value_int64)(sqlite3_value*); 154 | int (*value_numeric_type)(sqlite3_value*); 155 | const unsigned char * (*value_text)(sqlite3_value*); 156 | const void * (*value_text16)(sqlite3_value*); 157 | const void * (*value_text16be)(sqlite3_value*); 158 | const void * (*value_text16le)(sqlite3_value*); 159 | int (*value_type)(sqlite3_value*); 160 | char *(*vmprintf)(const char*,va_list); 161 | /* Added ??? */ 162 | int (*overload_function)(sqlite3*, const char *zFuncName, int nArg); 163 | /* Added by 3.3.13 */ 164 | int (*prepare_v2)(sqlite3*,const char*,int,sqlite3_stmt**,const char**); 165 | int (*prepare16_v2)(sqlite3*,const void*,int,sqlite3_stmt**,const void**); 166 | int (*clear_bindings)(sqlite3_stmt*); 167 | /* Added by 3.4.1 */ 168 | int (*create_module_v2)(sqlite3*,const char*,const sqlite3_module*,void*, 169 | void (*xDestroy)(void *)); 170 | /* Added by 3.5.0 */ 171 | int (*bind_zeroblob)(sqlite3_stmt*,int,int); 172 | int (*blob_bytes)(sqlite3_blob*); 173 | int (*blob_close)(sqlite3_blob*); 174 | int (*blob_open)(sqlite3*,const char*,const char*,const char*,sqlite3_int64, 175 | int,sqlite3_blob**); 176 | int (*blob_read)(sqlite3_blob*,void*,int,int); 177 | int (*blob_write)(sqlite3_blob*,const void*,int,int); 178 | int (*create_collation_v2)(sqlite3*,const char*,int,void*, 179 | int(*)(void*,int,const void*,int,const void*), 180 | void(*)(void*)); 181 | int (*file_control)(sqlite3*,const char*,int,void*); 182 | sqlite3_int64 (*memory_highwater)(int); 183 | sqlite3_int64 (*memory_used)(void); 184 | sqlite3_mutex *(*mutex_alloc)(int); 185 | void (*mutex_enter)(sqlite3_mutex*); 186 | void (*mutex_free)(sqlite3_mutex*); 187 | void (*mutex_leave)(sqlite3_mutex*); 188 | int (*mutex_try)(sqlite3_mutex*); 189 | int (*open_v2)(const char*,sqlite3**,int,const char*); 190 | int (*release_memory)(int); 191 | void (*result_error_nomem)(sqlite3_context*); 192 | void (*result_error_toobig)(sqlite3_context*); 193 | int (*sleep)(int); 194 | void (*soft_heap_limit)(int); 195 | sqlite3_vfs *(*vfs_find)(const char*); 196 | int (*vfs_register)(sqlite3_vfs*,int); 197 | int (*vfs_unregister)(sqlite3_vfs*); 198 | int (*xthreadsafe)(void); 199 | void (*result_zeroblob)(sqlite3_context*,int); 200 | void (*result_error_code)(sqlite3_context*,int); 201 | int (*test_control)(int, ...); 202 | void (*randomness)(int,void*); 203 | sqlite3 *(*context_db_handle)(sqlite3_context*); 204 | int (*extended_result_codes)(sqlite3*,int); 205 | int (*limit)(sqlite3*,int,int); 206 | sqlite3_stmt *(*next_stmt)(sqlite3*,sqlite3_stmt*); 207 | const char *(*sql)(sqlite3_stmt*); 208 | int (*status)(int,int*,int*,int); 209 | int (*backup_finish)(sqlite3_backup*); 210 | sqlite3_backup *(*backup_init)(sqlite3*,const char*,sqlite3*,const char*); 211 | int (*backup_pagecount)(sqlite3_backup*); 212 | int (*backup_remaining)(sqlite3_backup*); 213 | int (*backup_step)(sqlite3_backup*,int); 214 | const char *(*compileoption_get)(int); 215 | int (*compileoption_used)(const char*); 216 | int (*create_function_v2)(sqlite3*,const char*,int,int,void*, 217 | void (*xFunc)(sqlite3_context*,int,sqlite3_value**), 218 | void (*xStep)(sqlite3_context*,int,sqlite3_value**), 219 | void (*xFinal)(sqlite3_context*), 220 | void(*xDestroy)(void*)); 221 | int (*db_config)(sqlite3*,int,...); 222 | sqlite3_mutex *(*db_mutex)(sqlite3*); 223 | int (*db_status)(sqlite3*,int,int*,int*,int); 224 | int (*extended_errcode)(sqlite3*); 225 | void (*log)(int,const char*,...); 226 | sqlite3_int64 (*soft_heap_limit64)(sqlite3_int64); 227 | const char *(*sourceid)(void); 228 | int (*stmt_status)(sqlite3_stmt*,int,int); 229 | int (*strnicmp)(const char*,const char*,int); 230 | int (*unlock_notify)(sqlite3*,void(*)(void**,int),void*); 231 | int (*wal_autocheckpoint)(sqlite3*,int); 232 | int (*wal_checkpoint)(sqlite3*,const char*); 233 | void *(*wal_hook)(sqlite3*,int(*)(void*,sqlite3*,const char*,int),void*); 234 | int (*blob_reopen)(sqlite3_blob*,sqlite3_int64); 235 | int (*vtab_config)(sqlite3*,int op,...); 236 | int (*vtab_on_conflict)(sqlite3*); 237 | /* Version 3.7.16 and later */ 238 | int (*close_v2)(sqlite3*); 239 | const char *(*db_filename)(sqlite3*,const char*); 240 | int (*db_readonly)(sqlite3*,const char*); 241 | int (*db_release_memory)(sqlite3*); 242 | const char *(*errstr)(int); 243 | int (*stmt_busy)(sqlite3_stmt*); 244 | int (*stmt_readonly)(sqlite3_stmt*); 245 | int (*stricmp)(const char*,const char*); 246 | int (*uri_boolean)(const char*,const char*,int); 247 | sqlite3_int64 (*uri_int64)(const char*,const char*,sqlite3_int64); 248 | const char *(*uri_parameter)(const char*,const char*); 249 | char *(*xvsnprintf)(int,char*,const char*,va_list); 250 | int (*wal_checkpoint_v2)(sqlite3*,const char*,int,int*,int*); 251 | /* Version 3.8.7 and later */ 252 | int (*auto_extension)(void(*)(void)); 253 | int (*bind_blob64)(sqlite3_stmt*,int,const void*,sqlite3_uint64, 254 | void(*)(void*)); 255 | int (*bind_text64)(sqlite3_stmt*,int,const char*,sqlite3_uint64, 256 | void(*)(void*),unsigned char); 257 | int (*cancel_auto_extension)(void(*)(void)); 258 | int (*load_extension)(sqlite3*,const char*,const char*,char**); 259 | void *(*malloc64)(sqlite3_uint64); 260 | sqlite3_uint64 (*msize)(void*); 261 | void *(*realloc64)(void*,sqlite3_uint64); 262 | void (*reset_auto_extension)(void); 263 | void (*result_blob64)(sqlite3_context*,const void*,sqlite3_uint64, 264 | void(*)(void*)); 265 | void (*result_text64)(sqlite3_context*,const char*,sqlite3_uint64, 266 | void(*)(void*), unsigned char); 267 | int (*strglob)(const char*,const char*); 268 | /* Version 3.8.11 and later */ 269 | sqlite3_value *(*value_dup)(const sqlite3_value*); 270 | void (*value_free)(sqlite3_value*); 271 | int (*result_zeroblob64)(sqlite3_context*,sqlite3_uint64); 272 | int (*bind_zeroblob64)(sqlite3_stmt*, int, sqlite3_uint64); 273 | /* Version 3.9.0 and later */ 274 | unsigned int (*value_subtype)(sqlite3_value*); 275 | void (*result_subtype)(sqlite3_context*,unsigned int); 276 | /* Version 3.10.0 and later */ 277 | int (*status64)(int,sqlite3_int64*,sqlite3_int64*,int); 278 | int (*strlike)(const char*,const char*,unsigned int); 279 | int (*db_cacheflush)(sqlite3*); 280 | /* Version 3.12.0 and later */ 281 | int (*system_errno)(sqlite3*); 282 | /* Version 3.14.0 and later */ 283 | int (*trace_v2)(sqlite3*,unsigned,int(*)(unsigned,void*,void*,void*),void*); 284 | char *(*expanded_sql)(sqlite3_stmt*); 285 | /* Version 3.18.0 and later */ 286 | void (*set_last_insert_rowid)(sqlite3*,sqlite3_int64); 287 | /* Version 3.20.0 and later */ 288 | int (*prepare_v3)(sqlite3*,const char*,int,unsigned int, 289 | sqlite3_stmt**,const char**); 290 | int (*prepare16_v3)(sqlite3*,const void*,int,unsigned int, 291 | sqlite3_stmt**,const void**); 292 | int (*bind_pointer)(sqlite3_stmt*,int,void*,const char*,void(*)(void*)); 293 | void (*result_pointer)(sqlite3_context*,void*,const char*,void(*)(void*)); 294 | void *(*value_pointer)(sqlite3_value*,const char*); 295 | int (*vtab_nochange)(sqlite3_context*); 296 | int (*value_nochange)(sqlite3_value*); 297 | const char *(*vtab_collation)(sqlite3_index_info*,int); 298 | /* Version 3.24.0 and later */ 299 | int (*keyword_count)(void); 300 | int (*keyword_name)(int,const char**,int*); 301 | int (*keyword_check)(const char*,int); 302 | sqlite3_str *(*str_new)(sqlite3*); 303 | char *(*str_finish)(sqlite3_str*); 304 | void (*str_appendf)(sqlite3_str*, const char *zFormat, ...); 305 | void (*str_vappendf)(sqlite3_str*, const char *zFormat, va_list); 306 | void (*str_append)(sqlite3_str*, const char *zIn, int N); 307 | void (*str_appendall)(sqlite3_str*, const char *zIn); 308 | void (*str_appendchar)(sqlite3_str*, int N, char C); 309 | void (*str_reset)(sqlite3_str*); 310 | int (*str_errcode)(sqlite3_str*); 311 | int (*str_length)(sqlite3_str*); 312 | char *(*str_value)(sqlite3_str*); 313 | /* Version 3.25.0 and later */ 314 | int (*create_window_function)(sqlite3*,const char*,int,int,void*, 315 | void (*xStep)(sqlite3_context*,int,sqlite3_value**), 316 | void (*xFinal)(sqlite3_context*), 317 | void (*xValue)(sqlite3_context*), 318 | void (*xInv)(sqlite3_context*,int,sqlite3_value**), 319 | void(*xDestroy)(void*)); 320 | /* Version 3.26.0 and later */ 321 | const char *(*normalized_sql)(sqlite3_stmt*); 322 | /* Version 3.28.0 and later */ 323 | int (*stmt_isexplain)(sqlite3_stmt*); 324 | int (*value_frombind)(sqlite3_value*); 325 | /* Version 3.30.0 and later */ 326 | int (*drop_modules)(sqlite3*,const char**); 327 | /* Version 3.31.0 and later */ 328 | sqlite3_int64 (*hard_heap_limit64)(sqlite3_int64); 329 | const char *(*uri_key)(const char*,int); 330 | const char *(*filename_database)(const char*); 331 | const char *(*filename_journal)(const char*); 332 | const char *(*filename_wal)(const char*); 333 | /* Version 3.32.0 and later */ 334 | const char *(*create_filename)(const char*,const char*,const char*, 335 | int,const char**); 336 | void (*free_filename)(const char*); 337 | sqlite3_file *(*database_file_object)(const char*); 338 | /* Version 3.34.0 and later */ 339 | int (*txn_state)(sqlite3*,const char*); 340 | /* Version 3.36.1 and later */ 341 | sqlite3_int64 (*changes64)(sqlite3*); 342 | sqlite3_int64 (*total_changes64)(sqlite3*); 343 | /* Version 3.37.0 and later */ 344 | int (*autovacuum_pages)(sqlite3*, 345 | unsigned int(*)(void*,const char*,unsigned int,unsigned int,unsigned int), 346 | void*, void(*)(void*)); 347 | /* Version 3.38.0 and later */ 348 | int (*error_offset)(sqlite3*); 349 | int (*vtab_rhs_value)(sqlite3_index_info*,int,sqlite3_value**); 350 | int (*vtab_distinct)(sqlite3_index_info*); 351 | int (*vtab_in)(sqlite3_index_info*,int,int); 352 | int (*vtab_in_first)(sqlite3_value*,sqlite3_value**); 353 | int (*vtab_in_next)(sqlite3_value*,sqlite3_value**); 354 | /* Version 3.39.0 and later */ 355 | int (*deserialize)(sqlite3*,const char*,unsigned char*, 356 | sqlite3_int64,sqlite3_int64,unsigned); 357 | unsigned char *(*serialize)(sqlite3*,const char *,sqlite3_int64*, 358 | unsigned int); 359 | const char *(*db_name)(sqlite3*,int); 360 | /* Version 3.40.0 and later */ 361 | int (*value_encoding)(sqlite3_value*); 362 | /* Version 3.41.0 and later */ 363 | int (*is_interrupted)(sqlite3*); 364 | /* Version 3.43.0 and later */ 365 | int (*stmt_explain)(sqlite3_stmt*,int); 366 | /* Version 3.44.0 and later */ 367 | void *(*get_clientdata)(sqlite3*,const char*); 368 | int (*set_clientdata)(sqlite3*, const char*, void*, void(*)(void*)); 369 | }; 370 | 371 | /* 372 | ** This is the function signature used for all extension entry points. It 373 | ** is also defined in the file "loadext.c". 374 | */ 375 | typedef int (*sqlite3_loadext_entry)( 376 | sqlite3 *db, /* Handle to the database. */ 377 | char **pzErrMsg, /* Used to set error string on failure. */ 378 | const sqlite3_api_routines *pThunk /* Extension API function pointers. */ 379 | ); 380 | 381 | /* 382 | ** The following macros redefine the API routines so that they are 383 | ** redirected through the global sqlite3_api structure. 384 | ** 385 | ** This header file is also used by the loadext.c source file 386 | ** (part of the main SQLite library - not an extension) so that 387 | ** it can get access to the sqlite3_api_routines structure 388 | ** definition. But the main library does not want to redefine 389 | ** the API. So the redefinition macros are only valid if the 390 | ** SQLITE_CORE macros is undefined. 391 | */ 392 | #if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) 393 | #define sqlite3_aggregate_context sqlite3_api->aggregate_context 394 | #ifndef SQLITE_OMIT_DEPRECATED 395 | #define sqlite3_aggregate_count sqlite3_api->aggregate_count 396 | #endif 397 | #define sqlite3_bind_blob sqlite3_api->bind_blob 398 | #define sqlite3_bind_double sqlite3_api->bind_double 399 | #define sqlite3_bind_int sqlite3_api->bind_int 400 | #define sqlite3_bind_int64 sqlite3_api->bind_int64 401 | #define sqlite3_bind_null sqlite3_api->bind_null 402 | #define sqlite3_bind_parameter_count sqlite3_api->bind_parameter_count 403 | #define sqlite3_bind_parameter_index sqlite3_api->bind_parameter_index 404 | #define sqlite3_bind_parameter_name sqlite3_api->bind_parameter_name 405 | #define sqlite3_bind_text sqlite3_api->bind_text 406 | #define sqlite3_bind_text16 sqlite3_api->bind_text16 407 | #define sqlite3_bind_value sqlite3_api->bind_value 408 | #define sqlite3_busy_handler sqlite3_api->busy_handler 409 | #define sqlite3_busy_timeout sqlite3_api->busy_timeout 410 | #define sqlite3_changes sqlite3_api->changes 411 | #define sqlite3_close sqlite3_api->close 412 | #define sqlite3_collation_needed sqlite3_api->collation_needed 413 | #define sqlite3_collation_needed16 sqlite3_api->collation_needed16 414 | #define sqlite3_column_blob sqlite3_api->column_blob 415 | #define sqlite3_column_bytes sqlite3_api->column_bytes 416 | #define sqlite3_column_bytes16 sqlite3_api->column_bytes16 417 | #define sqlite3_column_count sqlite3_api->column_count 418 | #define sqlite3_column_database_name sqlite3_api->column_database_name 419 | #define sqlite3_column_database_name16 sqlite3_api->column_database_name16 420 | #define sqlite3_column_decltype sqlite3_api->column_decltype 421 | #define sqlite3_column_decltype16 sqlite3_api->column_decltype16 422 | #define sqlite3_column_double sqlite3_api->column_double 423 | #define sqlite3_column_int sqlite3_api->column_int 424 | #define sqlite3_column_int64 sqlite3_api->column_int64 425 | #define sqlite3_column_name sqlite3_api->column_name 426 | #define sqlite3_column_name16 sqlite3_api->column_name16 427 | #define sqlite3_column_origin_name sqlite3_api->column_origin_name 428 | #define sqlite3_column_origin_name16 sqlite3_api->column_origin_name16 429 | #define sqlite3_column_table_name sqlite3_api->column_table_name 430 | #define sqlite3_column_table_name16 sqlite3_api->column_table_name16 431 | #define sqlite3_column_text sqlite3_api->column_text 432 | #define sqlite3_column_text16 sqlite3_api->column_text16 433 | #define sqlite3_column_type sqlite3_api->column_type 434 | #define sqlite3_column_value sqlite3_api->column_value 435 | #define sqlite3_commit_hook sqlite3_api->commit_hook 436 | #define sqlite3_complete sqlite3_api->complete 437 | #define sqlite3_complete16 sqlite3_api->complete16 438 | #define sqlite3_create_collation sqlite3_api->create_collation 439 | #define sqlite3_create_collation16 sqlite3_api->create_collation16 440 | #define sqlite3_create_function sqlite3_api->create_function 441 | #define sqlite3_create_function16 sqlite3_api->create_function16 442 | #define sqlite3_create_module sqlite3_api->create_module 443 | #define sqlite3_create_module_v2 sqlite3_api->create_module_v2 444 | #define sqlite3_data_count sqlite3_api->data_count 445 | #define sqlite3_db_handle sqlite3_api->db_handle 446 | #define sqlite3_declare_vtab sqlite3_api->declare_vtab 447 | #define sqlite3_enable_shared_cache sqlite3_api->enable_shared_cache 448 | #define sqlite3_errcode sqlite3_api->errcode 449 | #define sqlite3_errmsg sqlite3_api->errmsg 450 | #define sqlite3_errmsg16 sqlite3_api->errmsg16 451 | #define sqlite3_exec sqlite3_api->exec 452 | #ifndef SQLITE_OMIT_DEPRECATED 453 | #define sqlite3_expired sqlite3_api->expired 454 | #endif 455 | #define sqlite3_finalize sqlite3_api->finalize 456 | #define sqlite3_free sqlite3_api->free 457 | #define sqlite3_free_table sqlite3_api->free_table 458 | #define sqlite3_get_autocommit sqlite3_api->get_autocommit 459 | #define sqlite3_get_auxdata sqlite3_api->get_auxdata 460 | #define sqlite3_get_table sqlite3_api->get_table 461 | #ifndef SQLITE_OMIT_DEPRECATED 462 | #define sqlite3_global_recover sqlite3_api->global_recover 463 | #endif 464 | #define sqlite3_interrupt sqlite3_api->interruptx 465 | #define sqlite3_last_insert_rowid sqlite3_api->last_insert_rowid 466 | #define sqlite3_libversion sqlite3_api->libversion 467 | #define sqlite3_libversion_number sqlite3_api->libversion_number 468 | #define sqlite3_malloc sqlite3_api->malloc 469 | #define sqlite3_mprintf sqlite3_api->mprintf 470 | #define sqlite3_open sqlite3_api->open 471 | #define sqlite3_open16 sqlite3_api->open16 472 | #define sqlite3_prepare sqlite3_api->prepare 473 | #define sqlite3_prepare16 sqlite3_api->prepare16 474 | #define sqlite3_prepare_v2 sqlite3_api->prepare_v2 475 | #define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2 476 | #define sqlite3_profile sqlite3_api->profile 477 | #define sqlite3_progress_handler sqlite3_api->progress_handler 478 | #define sqlite3_realloc sqlite3_api->realloc 479 | #define sqlite3_reset sqlite3_api->reset 480 | #define sqlite3_result_blob sqlite3_api->result_blob 481 | #define sqlite3_result_double sqlite3_api->result_double 482 | #define sqlite3_result_error sqlite3_api->result_error 483 | #define sqlite3_result_error16 sqlite3_api->result_error16 484 | #define sqlite3_result_int sqlite3_api->result_int 485 | #define sqlite3_result_int64 sqlite3_api->result_int64 486 | #define sqlite3_result_null sqlite3_api->result_null 487 | #define sqlite3_result_text sqlite3_api->result_text 488 | #define sqlite3_result_text16 sqlite3_api->result_text16 489 | #define sqlite3_result_text16be sqlite3_api->result_text16be 490 | #define sqlite3_result_text16le sqlite3_api->result_text16le 491 | #define sqlite3_result_value sqlite3_api->result_value 492 | #define sqlite3_rollback_hook sqlite3_api->rollback_hook 493 | #define sqlite3_set_authorizer sqlite3_api->set_authorizer 494 | #define sqlite3_set_auxdata sqlite3_api->set_auxdata 495 | #define sqlite3_snprintf sqlite3_api->xsnprintf 496 | #define sqlite3_step sqlite3_api->step 497 | #define sqlite3_table_column_metadata sqlite3_api->table_column_metadata 498 | #define sqlite3_thread_cleanup sqlite3_api->thread_cleanup 499 | #define sqlite3_total_changes sqlite3_api->total_changes 500 | #define sqlite3_trace sqlite3_api->trace 501 | #ifndef SQLITE_OMIT_DEPRECATED 502 | #define sqlite3_transfer_bindings sqlite3_api->transfer_bindings 503 | #endif 504 | #define sqlite3_update_hook sqlite3_api->update_hook 505 | #define sqlite3_user_data sqlite3_api->user_data 506 | #define sqlite3_value_blob sqlite3_api->value_blob 507 | #define sqlite3_value_bytes sqlite3_api->value_bytes 508 | #define sqlite3_value_bytes16 sqlite3_api->value_bytes16 509 | #define sqlite3_value_double sqlite3_api->value_double 510 | #define sqlite3_value_int sqlite3_api->value_int 511 | #define sqlite3_value_int64 sqlite3_api->value_int64 512 | #define sqlite3_value_numeric_type sqlite3_api->value_numeric_type 513 | #define sqlite3_value_text sqlite3_api->value_text 514 | #define sqlite3_value_text16 sqlite3_api->value_text16 515 | #define sqlite3_value_text16be sqlite3_api->value_text16be 516 | #define sqlite3_value_text16le sqlite3_api->value_text16le 517 | #define sqlite3_value_type sqlite3_api->value_type 518 | #define sqlite3_vmprintf sqlite3_api->vmprintf 519 | #define sqlite3_vsnprintf sqlite3_api->xvsnprintf 520 | #define sqlite3_overload_function sqlite3_api->overload_function 521 | #define sqlite3_prepare_v2 sqlite3_api->prepare_v2 522 | #define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2 523 | #define sqlite3_clear_bindings sqlite3_api->clear_bindings 524 | #define sqlite3_bind_zeroblob sqlite3_api->bind_zeroblob 525 | #define sqlite3_blob_bytes sqlite3_api->blob_bytes 526 | #define sqlite3_blob_close sqlite3_api->blob_close 527 | #define sqlite3_blob_open sqlite3_api->blob_open 528 | #define sqlite3_blob_read sqlite3_api->blob_read 529 | #define sqlite3_blob_write sqlite3_api->blob_write 530 | #define sqlite3_create_collation_v2 sqlite3_api->create_collation_v2 531 | #define sqlite3_file_control sqlite3_api->file_control 532 | #define sqlite3_memory_highwater sqlite3_api->memory_highwater 533 | #define sqlite3_memory_used sqlite3_api->memory_used 534 | #define sqlite3_mutex_alloc sqlite3_api->mutex_alloc 535 | #define sqlite3_mutex_enter sqlite3_api->mutex_enter 536 | #define sqlite3_mutex_free sqlite3_api->mutex_free 537 | #define sqlite3_mutex_leave sqlite3_api->mutex_leave 538 | #define sqlite3_mutex_try sqlite3_api->mutex_try 539 | #define sqlite3_open_v2 sqlite3_api->open_v2 540 | #define sqlite3_release_memory sqlite3_api->release_memory 541 | #define sqlite3_result_error_nomem sqlite3_api->result_error_nomem 542 | #define sqlite3_result_error_toobig sqlite3_api->result_error_toobig 543 | #define sqlite3_sleep sqlite3_api->sleep 544 | #define sqlite3_soft_heap_limit sqlite3_api->soft_heap_limit 545 | #define sqlite3_vfs_find sqlite3_api->vfs_find 546 | #define sqlite3_vfs_register sqlite3_api->vfs_register 547 | #define sqlite3_vfs_unregister sqlite3_api->vfs_unregister 548 | #define sqlite3_threadsafe sqlite3_api->xthreadsafe 549 | #define sqlite3_result_zeroblob sqlite3_api->result_zeroblob 550 | #define sqlite3_result_error_code sqlite3_api->result_error_code 551 | #define sqlite3_test_control sqlite3_api->test_control 552 | #define sqlite3_randomness sqlite3_api->randomness 553 | #define sqlite3_context_db_handle sqlite3_api->context_db_handle 554 | #define sqlite3_extended_result_codes sqlite3_api->extended_result_codes 555 | #define sqlite3_limit sqlite3_api->limit 556 | #define sqlite3_next_stmt sqlite3_api->next_stmt 557 | #define sqlite3_sql sqlite3_api->sql 558 | #define sqlite3_status sqlite3_api->status 559 | #define sqlite3_backup_finish sqlite3_api->backup_finish 560 | #define sqlite3_backup_init sqlite3_api->backup_init 561 | #define sqlite3_backup_pagecount sqlite3_api->backup_pagecount 562 | #define sqlite3_backup_remaining sqlite3_api->backup_remaining 563 | #define sqlite3_backup_step sqlite3_api->backup_step 564 | #define sqlite3_compileoption_get sqlite3_api->compileoption_get 565 | #define sqlite3_compileoption_used sqlite3_api->compileoption_used 566 | #define sqlite3_create_function_v2 sqlite3_api->create_function_v2 567 | #define sqlite3_db_config sqlite3_api->db_config 568 | #define sqlite3_db_mutex sqlite3_api->db_mutex 569 | #define sqlite3_db_status sqlite3_api->db_status 570 | #define sqlite3_extended_errcode sqlite3_api->extended_errcode 571 | #define sqlite3_log sqlite3_api->log 572 | #define sqlite3_soft_heap_limit64 sqlite3_api->soft_heap_limit64 573 | #define sqlite3_sourceid sqlite3_api->sourceid 574 | #define sqlite3_stmt_status sqlite3_api->stmt_status 575 | #define sqlite3_strnicmp sqlite3_api->strnicmp 576 | #define sqlite3_unlock_notify sqlite3_api->unlock_notify 577 | #define sqlite3_wal_autocheckpoint sqlite3_api->wal_autocheckpoint 578 | #define sqlite3_wal_checkpoint sqlite3_api->wal_checkpoint 579 | #define sqlite3_wal_hook sqlite3_api->wal_hook 580 | #define sqlite3_blob_reopen sqlite3_api->blob_reopen 581 | #define sqlite3_vtab_config sqlite3_api->vtab_config 582 | #define sqlite3_vtab_on_conflict sqlite3_api->vtab_on_conflict 583 | /* Version 3.7.16 and later */ 584 | #define sqlite3_close_v2 sqlite3_api->close_v2 585 | #define sqlite3_db_filename sqlite3_api->db_filename 586 | #define sqlite3_db_readonly sqlite3_api->db_readonly 587 | #define sqlite3_db_release_memory sqlite3_api->db_release_memory 588 | #define sqlite3_errstr sqlite3_api->errstr 589 | #define sqlite3_stmt_busy sqlite3_api->stmt_busy 590 | #define sqlite3_stmt_readonly sqlite3_api->stmt_readonly 591 | #define sqlite3_stricmp sqlite3_api->stricmp 592 | #define sqlite3_uri_boolean sqlite3_api->uri_boolean 593 | #define sqlite3_uri_int64 sqlite3_api->uri_int64 594 | #define sqlite3_uri_parameter sqlite3_api->uri_parameter 595 | #define sqlite3_uri_vsnprintf sqlite3_api->xvsnprintf 596 | #define sqlite3_wal_checkpoint_v2 sqlite3_api->wal_checkpoint_v2 597 | /* Version 3.8.7 and later */ 598 | #define sqlite3_auto_extension sqlite3_api->auto_extension 599 | #define sqlite3_bind_blob64 sqlite3_api->bind_blob64 600 | #define sqlite3_bind_text64 sqlite3_api->bind_text64 601 | #define sqlite3_cancel_auto_extension sqlite3_api->cancel_auto_extension 602 | #define sqlite3_load_extension sqlite3_api->load_extension 603 | #define sqlite3_malloc64 sqlite3_api->malloc64 604 | #define sqlite3_msize sqlite3_api->msize 605 | #define sqlite3_realloc64 sqlite3_api->realloc64 606 | #define sqlite3_reset_auto_extension sqlite3_api->reset_auto_extension 607 | #define sqlite3_result_blob64 sqlite3_api->result_blob64 608 | #define sqlite3_result_text64 sqlite3_api->result_text64 609 | #define sqlite3_strglob sqlite3_api->strglob 610 | /* Version 3.8.11 and later */ 611 | #define sqlite3_value_dup sqlite3_api->value_dup 612 | #define sqlite3_value_free sqlite3_api->value_free 613 | #define sqlite3_result_zeroblob64 sqlite3_api->result_zeroblob64 614 | #define sqlite3_bind_zeroblob64 sqlite3_api->bind_zeroblob64 615 | /* Version 3.9.0 and later */ 616 | #define sqlite3_value_subtype sqlite3_api->value_subtype 617 | #define sqlite3_result_subtype sqlite3_api->result_subtype 618 | /* Version 3.10.0 and later */ 619 | #define sqlite3_status64 sqlite3_api->status64 620 | #define sqlite3_strlike sqlite3_api->strlike 621 | #define sqlite3_db_cacheflush sqlite3_api->db_cacheflush 622 | /* Version 3.12.0 and later */ 623 | #define sqlite3_system_errno sqlite3_api->system_errno 624 | /* Version 3.14.0 and later */ 625 | #define sqlite3_trace_v2 sqlite3_api->trace_v2 626 | #define sqlite3_expanded_sql sqlite3_api->expanded_sql 627 | /* Version 3.18.0 and later */ 628 | #define sqlite3_set_last_insert_rowid sqlite3_api->set_last_insert_rowid 629 | /* Version 3.20.0 and later */ 630 | #define sqlite3_prepare_v3 sqlite3_api->prepare_v3 631 | #define sqlite3_prepare16_v3 sqlite3_api->prepare16_v3 632 | #define sqlite3_bind_pointer sqlite3_api->bind_pointer 633 | #define sqlite3_result_pointer sqlite3_api->result_pointer 634 | #define sqlite3_value_pointer sqlite3_api->value_pointer 635 | /* Version 3.22.0 and later */ 636 | #define sqlite3_vtab_nochange sqlite3_api->vtab_nochange 637 | #define sqlite3_value_nochange sqlite3_api->value_nochange 638 | #define sqlite3_vtab_collation sqlite3_api->vtab_collation 639 | /* Version 3.24.0 and later */ 640 | #define sqlite3_keyword_count sqlite3_api->keyword_count 641 | #define sqlite3_keyword_name sqlite3_api->keyword_name 642 | #define sqlite3_keyword_check sqlite3_api->keyword_check 643 | #define sqlite3_str_new sqlite3_api->str_new 644 | #define sqlite3_str_finish sqlite3_api->str_finish 645 | #define sqlite3_str_appendf sqlite3_api->str_appendf 646 | #define sqlite3_str_vappendf sqlite3_api->str_vappendf 647 | #define sqlite3_str_append sqlite3_api->str_append 648 | #define sqlite3_str_appendall sqlite3_api->str_appendall 649 | #define sqlite3_str_appendchar sqlite3_api->str_appendchar 650 | #define sqlite3_str_reset sqlite3_api->str_reset 651 | #define sqlite3_str_errcode sqlite3_api->str_errcode 652 | #define sqlite3_str_length sqlite3_api->str_length 653 | #define sqlite3_str_value sqlite3_api->str_value 654 | /* Version 3.25.0 and later */ 655 | #define sqlite3_create_window_function sqlite3_api->create_window_function 656 | /* Version 3.26.0 and later */ 657 | #define sqlite3_normalized_sql sqlite3_api->normalized_sql 658 | /* Version 3.28.0 and later */ 659 | #define sqlite3_stmt_isexplain sqlite3_api->stmt_isexplain 660 | #define sqlite3_value_frombind sqlite3_api->value_frombind 661 | /* Version 3.30.0 and later */ 662 | #define sqlite3_drop_modules sqlite3_api->drop_modules 663 | /* Version 3.31.0 and later */ 664 | #define sqlite3_hard_heap_limit64 sqlite3_api->hard_heap_limit64 665 | #define sqlite3_uri_key sqlite3_api->uri_key 666 | #define sqlite3_filename_database sqlite3_api->filename_database 667 | #define sqlite3_filename_journal sqlite3_api->filename_journal 668 | #define sqlite3_filename_wal sqlite3_api->filename_wal 669 | /* Version 3.32.0 and later */ 670 | #define sqlite3_create_filename sqlite3_api->create_filename 671 | #define sqlite3_free_filename sqlite3_api->free_filename 672 | #define sqlite3_database_file_object sqlite3_api->database_file_object 673 | /* Version 3.34.0 and later */ 674 | #define sqlite3_txn_state sqlite3_api->txn_state 675 | /* Version 3.36.1 and later */ 676 | #define sqlite3_changes64 sqlite3_api->changes64 677 | #define sqlite3_total_changes64 sqlite3_api->total_changes64 678 | /* Version 3.37.0 and later */ 679 | #define sqlite3_autovacuum_pages sqlite3_api->autovacuum_pages 680 | /* Version 3.38.0 and later */ 681 | #define sqlite3_error_offset sqlite3_api->error_offset 682 | #define sqlite3_vtab_rhs_value sqlite3_api->vtab_rhs_value 683 | #define sqlite3_vtab_distinct sqlite3_api->vtab_distinct 684 | #define sqlite3_vtab_in sqlite3_api->vtab_in 685 | #define sqlite3_vtab_in_first sqlite3_api->vtab_in_first 686 | #define sqlite3_vtab_in_next sqlite3_api->vtab_in_next 687 | /* Version 3.39.0 and later */ 688 | #ifndef SQLITE_OMIT_DESERIALIZE 689 | #define sqlite3_deserialize sqlite3_api->deserialize 690 | #define sqlite3_serialize sqlite3_api->serialize 691 | #endif 692 | #define sqlite3_db_name sqlite3_api->db_name 693 | /* Version 3.40.0 and later */ 694 | #define sqlite3_value_encoding sqlite3_api->value_encoding 695 | /* Version 3.41.0 and later */ 696 | #define sqlite3_is_interrupted sqlite3_api->is_interrupted 697 | /* Version 3.43.0 and later */ 698 | #define sqlite3_stmt_explain sqlite3_api->stmt_explain 699 | /* Version 3.44.0 and later */ 700 | #define sqlite3_get_clientdata sqlite3_api->get_clientdata 701 | #define sqlite3_set_clientdata sqlite3_api->set_clientdata 702 | #endif /* !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) */ 703 | 704 | #if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) 705 | /* This case when the file really is being compiled as a loadable 706 | ** extension */ 707 | # define SQLITE_EXTENSION_INIT1 const sqlite3_api_routines *sqlite3_api=0; 708 | # define SQLITE_EXTENSION_INIT2(v) sqlite3_api=v; 709 | # define SQLITE_EXTENSION_INIT3 \ 710 | extern const sqlite3_api_routines *sqlite3_api; 711 | #else 712 | /* This case when the file is being statically linked into the 713 | ** application */ 714 | # define SQLITE_EXTENSION_INIT1 /*no-op*/ 715 | # define SQLITE_EXTENSION_INIT2(v) (void)v; /* unused parameter */ 716 | # define SQLITE_EXTENSION_INIT3 /*no-op*/ 717 | #endif 718 | 719 | #endif /* SQLITE3EXT_H */ 720 | -------------------------------------------------------------------------------- /res/resource.rc: -------------------------------------------------------------------------------- 1 | 1 ICON "webmaker2000.ico" 2 | -------------------------------------------------------------------------------- /res/webmaker2000.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhanb/webmaker2000/8cec57b115bd28379312e708c10e3632598abc1d/res/webmaker2000.ico -------------------------------------------------------------------------------- /screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhanb/webmaker2000/8cec57b115bd28379312e708c10e3632598abc1d/screenshot.webp -------------------------------------------------------------------------------- /src/Adwaita.zig: -------------------------------------------------------------------------------- 1 | const dvui = @import("dvui"); 2 | 3 | const Color = dvui.Color; 4 | const Font = dvui.Font; 5 | const Theme = dvui.Theme; 6 | const Options = dvui.Options; 7 | 8 | const accent = Color{ .r = 0x35, .g = 0x84, .b = 0xe4 }; 9 | const accent_hsl = Color.HSLuv.fromColor(accent); 10 | const err = Color{ .r = 0xe0, .g = 0x1b, .b = 0x24 }; 11 | const err_hsl = Color.HSLuv.fromColor(err); 12 | 13 | // need to have these as separate variables because inlining them below trips 14 | // zig's comptime eval quota 15 | const light_accent_accent = accent_hsl.lighten(-16).color(); 16 | const light_accent_fill = accent_hsl.color(); 17 | const light_accent_fill_hover = accent_hsl.lighten(-11).color(); 18 | const light_accent_border = accent_hsl.lighten(-22).color(); 19 | 20 | const light_err_accent = err_hsl.lighten(-15).color(); 21 | const light_err_fill = err_hsl.color(); 22 | const light_err_fill_hover = err_hsl.lighten(-10).color(); 23 | const light_err_border = err_hsl.lighten(-20).color(); 24 | 25 | pub const light = light: { 26 | @setEvalBranchQuota(3123); 27 | break :light Theme{ 28 | .name = "Adwaita Light", 29 | .dark = false, 30 | 31 | .font_body = .{ .size = 16, .name = "Vera" }, 32 | .font_heading = .{ .size = 16, .name = "VeraBd" }, 33 | .font_caption = .{ .size = 13, .name = "Vera", .line_height_factor = 1.1 }, 34 | .font_caption_heading = .{ .size = 13, .name = "VeraBd", .line_height_factor = 1.1 }, 35 | .font_title = .{ .size = 28, .name = "Vera" }, 36 | .font_title_1 = .{ .size = 24, .name = "VeraBd" }, 37 | .font_title_2 = .{ .size = 22, .name = "VeraBd" }, 38 | .font_title_3 = .{ .size = 20, .name = "VeraBd" }, 39 | .font_title_4 = .{ .size = 18, .name = "VeraBd" }, 40 | 41 | .color_accent = accent_hsl.color(), 42 | .color_err = err_hsl.color(), 43 | .color_text = Color.black, 44 | .color_text_press = Color.black, 45 | .color_fill = Color.white, 46 | .color_fill_window = .{ .r = 0xf0, .g = 0xf0, .b = 0xf0 }, 47 | .color_fill_control = .{ .r = 0xe0, .g = 0xe0, .b = 0xe0 }, 48 | .color_fill_hover = (Color.HSLuv{ .s = 0, .l = 82 }).color(), 49 | .color_fill_press = (Color.HSLuv{ .s = 0, .l = 72 }).color(), 50 | .color_border = (Color.HSLuv{ .s = 0, .l = 63 }).color(), 51 | 52 | .style_accent = Options{ 53 | .color_accent = .{ .color = light_accent_accent }, 54 | .color_text = .{ .color = Color.white }, 55 | .color_text_press = .{ .color = Color.white }, 56 | .color_fill = .{ .color = light_accent_fill }, 57 | .color_fill_hover = .{ .color = light_accent_fill_hover }, 58 | .color_fill_press = .{ .color = light_accent_accent }, 59 | .color_border = .{ .color = light_accent_border }, 60 | }, 61 | 62 | .style_err = Options{ 63 | .color_accent = .{ .color = light_err_accent }, 64 | .color_text = .{ .color = Color.white }, 65 | .color_text_press = .{ .color = Color.white }, 66 | .color_fill = .{ .color = light_err_fill }, 67 | .color_fill_hover = .{ .color = light_err_fill_hover }, 68 | .color_fill_press = .{ .color = light_err_accent }, 69 | .color_border = .{ .color = light_err_border }, 70 | }, 71 | }; 72 | }; 73 | 74 | const dark_fill = Color{ .r = 0x1e, .g = 0x1e, .b = 0x1e }; 75 | const dark_fill_hsl = Color.HSLuv.fromColor(dark_fill); 76 | const dark_err = Color{ .r = 0xc0, .g = 0x1c, .b = 0x28 }; 77 | const dark_err_hsl = Color.HSLuv.fromColor(dark_err); 78 | 79 | const dark_accent_accent = accent_hsl.lighten(12).color(); 80 | const dark_accent_fill_hover = accent_hsl.lighten(9).color(); 81 | const dark_accent_border = accent_hsl.lighten(17).color(); 82 | 83 | const dark_err_accent = dark_err_hsl.lighten(14).color(); 84 | const dark_err_fill_hover = err_hsl.lighten(9).color(); 85 | const dark_err_fill_press = err_hsl.lighten(16).color(); 86 | const dark_err_border = err_hsl.lighten(20).color(); 87 | 88 | pub const dark = dark: { 89 | @setEvalBranchQuota(3023); 90 | break :dark Theme{ 91 | .name = "Adwaita Dark", 92 | .dark = true, 93 | 94 | .font_body = .{ .size = 16, .name = "Vera" }, 95 | .font_heading = .{ .size = 16, .name = "VeraBd" }, 96 | .font_caption = .{ .size = 13, .name = "Vera", .line_height_factor = 1.1 }, 97 | .font_caption_heading = .{ .size = 13, .name = "VeraBd", .line_height_factor = 1.1 }, 98 | .font_title = .{ .size = 28, .name = "Vera" }, 99 | .font_title_1 = .{ .size = 24, .name = "VeraBd" }, 100 | .font_title_2 = .{ .size = 22, .name = "VeraBd" }, 101 | .font_title_3 = .{ .size = 20, .name = "VeraBd" }, 102 | .font_title_4 = .{ .size = 18, .name = "VeraBd" }, 103 | 104 | .color_accent = accent_hsl.color(), 105 | .color_err = dark_err, 106 | .color_text = Color.white, 107 | .color_text_press = Color.white, 108 | .color_fill = dark_fill, 109 | .color_fill_window = .{ .r = 0x2b, .g = 0x2b, .b = 0x2b }, 110 | .color_fill_control = .{ .r = 0x40, .g = 0x40, .b = 0x40 }, 111 | .color_fill_hover = dark_fill_hsl.lighten(21).color(), 112 | .color_fill_press = dark_fill_hsl.lighten(30).color(), 113 | .color_border = dark_fill_hsl.lighten(39).color(), 114 | 115 | .style_accent = Options{ 116 | .color_accent = .{ .color = dark_accent_accent }, 117 | .color_text = .{ .color = Color.white }, 118 | .color_text_press = .{ .color = Color.white }, 119 | .color_fill = .{ .color = accent }, 120 | .color_fill_hover = .{ .color = dark_accent_fill_hover }, 121 | .color_fill_press = .{ .color = dark_accent_accent }, 122 | .color_border = .{ .color = dark_accent_border }, 123 | }, 124 | 125 | .style_err = Options{ 126 | .color_accent = .{ .color = dark_err_accent }, 127 | .color_text = .{ .color = Color.white }, 128 | .color_text_press = .{ .color = Color.white }, 129 | .color_fill = .{ .color = dark_err }, 130 | .color_fill_hover = .{ .color = dark_err_fill_hover }, 131 | .color_fill_press = .{ .color = dark_err_fill_press }, 132 | .color_border = .{ .color = dark_err_border }, 133 | }, 134 | }; 135 | }; 136 | -------------------------------------------------------------------------------- /src/blobstore.zig: -------------------------------------------------------------------------------- 1 | // A content-addressable blob store where each blob is stored as 2 | // /blobs/ void, 17 | else => return err, 18 | }; 19 | } 20 | 21 | pub const BlobInfo = struct { 22 | hash: [HASH.digest_length * 2]u8, 23 | size: u64, 24 | }; 25 | 26 | /// Stores file at `src_abspath` in blobs dir, returns its hex digest 27 | /// TODO: It takes ~9s to hash a 2.6GiB file, while gnu coreutils' sha256sum 28 | /// command only takes 1.8s. There's room for improvement here. Also see 29 | /// 30 | /// TODO: Regardless, this is now long-running-command territory. 31 | /// I should implement some sort of progress report modal system soon. 32 | pub fn store(gpa: Allocator, src_abspath: []const u8) !BlobInfo { 33 | var src_size_bytes: usize = 0; 34 | var hash_hex: [HASH.digest_length * 2]u8 = undefined; 35 | 36 | { 37 | var hash_timer = try std.time.Timer.start(); 38 | defer { 39 | const hash_time_ms = hash_timer.read() / 1000 / 1000; 40 | if (hash_time_ms > 0) { 41 | println( 42 | "blobstore: {s} took {d}ms to hash", 43 | .{ hash_hex[0..7], hash_time_ms }, 44 | ); 45 | } 46 | } 47 | 48 | var src = try std.fs.openFileAbsolute(src_abspath, .{}); 49 | defer src.close(); 50 | 51 | var src_buf = try gpa.alloc(u8, 1024 * 1024 * 16); 52 | defer gpa.free(src_buf); 53 | 54 | // Stream src data into hasher: 55 | var hasher = HASH.init(.{}); 56 | while (true) { 57 | const bytes_read = try src.read(src_buf); 58 | if (bytes_read == 0) break; 59 | src_size_bytes += bytes_read; 60 | hasher.update(src_buf[0..bytes_read]); 61 | } 62 | const hash_bytes = hasher.finalResult(); 63 | hash_hex = std.fmt.bytesToHex(hash_bytes, .lower); 64 | } 65 | 66 | const file_path = try blob_path(hash_hex); 67 | 68 | if (std.fs.cwd().statFile(&file_path) catch |err| switch (err) { 69 | error.FileNotFound => null, 70 | else => return err, 71 | }) |stat| { 72 | if (stat.kind == .file) { 73 | println("blobstore: {s} already exists => skipped", .{hash_hex[0..7]}); 74 | return .{ 75 | .hash = hash_hex, 76 | .size = stat.size, 77 | }; 78 | } 79 | } 80 | 81 | try std.fs.Dir.copyFile( 82 | try std.fs.openDirAbsolute(std.fs.path.dirname(src_abspath).?, .{}), 83 | std.fs.path.basename(src_abspath), 84 | std.fs.cwd(), 85 | &file_path, 86 | .{}, 87 | ); 88 | 89 | println("blobstore: {s} stored", .{hash_hex[0..7]}); 90 | return .{ 91 | .hash = hash_hex, 92 | .size = src_size_bytes, 93 | }; 94 | } 95 | 96 | pub fn blob_path(hash: [HASH.digest_length * 2]u8) ![DIR.len + "/".len + HASH.digest_length * 2]u8 { 97 | var path: [DIR.len + "/".len + HASH.digest_length * 2]u8 = undefined; 98 | _ = try std.fmt.bufPrint(&path, "{s}/{s}", .{ DIR, &hash }); 99 | return path; 100 | } 101 | 102 | pub fn read(arena: Allocator, hash: [HASH.digest_length * 2]u8) ![]const u8 { 103 | const path = try blob_path(hash); 104 | return try std.fs.cwd().readFileAlloc(arena, &path, 9_223_372_036_854_775_807); 105 | } 106 | 107 | pub fn registerSqliteFunctions(conn: zqlite.Conn) !void { 108 | const ret = c.sqlite3_create_function( 109 | conn.conn, 110 | "blobstore_delete", 111 | 1, 112 | c.SQLITE_UTF8 | c.SQLITE_DETERMINISTIC, 113 | null, 114 | sqlite_blobstore_delete, 115 | null, 116 | null, 117 | ); 118 | 119 | if (ret != c.SQLITE_OK) { 120 | return error.CustomFunction; 121 | } 122 | } 123 | 124 | /// Sqlite application-defined function that deletes an on-disk blob 125 | export fn sqlite_blobstore_delete( 126 | context: ?*c.sqlite3_context, 127 | argc: c_int, 128 | argv: [*c]?*c.sqlite3_value, 129 | ) void { 130 | _ = argc; 131 | const blob_hash = std.mem.span(c.sqlite3_value_text(argv[0].?)); 132 | 133 | // TODO: how to handle errors in an sqlite application-defined function? 134 | var blobs_dir = std.fs.cwd().openDir(DIR, .{}) catch unreachable; 135 | defer blobs_dir.close(); 136 | 137 | blobs_dir.deleteFile(blob_hash) catch |err| switch (err) { 138 | std.fs.Dir.DeleteFileError.FileNotFound => { 139 | println("blobstore: {s} does not exist => skipped", .{blob_hash[0..7]}); 140 | c.sqlite3_result_int64(context, 0); 141 | return; 142 | }, 143 | // TODO: how to handle errors in an sqlite application-defined function? 144 | else => unreachable, 145 | }; 146 | 147 | println("blobstore: {s} deleted", .{blob_hash[0..7]}); 148 | c.sqlite3_result_int64(context, 1); 149 | } 150 | -------------------------------------------------------------------------------- /src/constants.zig: -------------------------------------------------------------------------------- 1 | pub const EXTENSION = "wm2k"; 2 | pub const SITE_FILE = "site." ++ EXTENSION; 3 | pub const PORT = 1177; 4 | pub const OUTPUT_DIR = "output"; 5 | -------------------------------------------------------------------------------- /src/core.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Allocator = std.mem.Allocator; 3 | 4 | const zqlite = @import("zqlite"); 5 | 6 | const history = @import("history.zig"); 7 | const sql = @import("sql.zig"); 8 | const queries = @import("queries.zig"); 9 | const maths = @import("maths.zig"); 10 | const constants = @import("constants.zig"); 11 | const blobstore = @import("blobstore.zig"); 12 | const println = @import("util.zig").println; 13 | 14 | pub const Core = struct { 15 | state: GuiState = undefined, 16 | maybe_conn: ?zqlite.Conn = null, 17 | 18 | pub fn deinit(self: *Core) void { 19 | if (self.maybe_conn) |conn| { 20 | // On exit, perform an sqlite WAL checkpoint: 21 | // https://www.sqlite.org/pragma.html#pragma_wal_checkpoint 22 | sql.execNoArgs(conn, "PRAGMA wal_checkpoint(TRUNCATE);") catch |err| { 23 | println("ERR during wal_checkpoint: {}", .{err}); 24 | }; 25 | conn.close(); 26 | } 27 | } 28 | 29 | pub fn handleAction(self: *Core, conn: zqlite.Conn, arena: Allocator, action: Action) !void { 30 | if (self.state == .no_file_opened) { 31 | return error.ActionNotImplemented; 32 | } 33 | 34 | try sql.execNoArgs(conn, "begin immediate"); 35 | errdefer conn.rollback(); 36 | 37 | try queries.clearStatusText(conn); 38 | 39 | const skip_history = history.shouldSkip(action); 40 | if (!skip_history) { 41 | try history.foldRedos(conn, self.state.opened.history.redos); 42 | } 43 | 44 | switch (action) { 45 | .create_post => { 46 | try sql.execNoArgs(conn, "insert into post default values"); 47 | const new_post_id = conn.lastInsertedRowId(); 48 | try sql.exec(conn, "update gui_scene set current_scene = ?", .{@intFromEnum(Scene.editing)}); 49 | try sql.exec(conn, "update gui_scene_editing set post_id = ?", .{new_post_id}); 50 | 51 | try queries.setStatusText(arena, conn, "Created post #{d}.", .{new_post_id}); 52 | }, 53 | .update_post_title => |data| { 54 | try sql.exec(conn, "update post set title=? where id=?", .{ data.title, data.id }); 55 | }, 56 | .update_post_slug => |data| { 57 | try sql.exec(conn, "update post set slug=? where id=?", .{ data.slug, data.id }); 58 | }, 59 | .update_post_content => |data| { 60 | try sql.exec(conn, "update post set content=? where id=?", .{ data.content, data.id }); 61 | }, 62 | .edit_post => |post_id| { 63 | try sql.exec(conn, "update gui_scene set current_scene = ?", .{@intFromEnum(Scene.editing)}); 64 | try sql.exec(conn, "update gui_scene_editing set post_id = ?", .{post_id}); 65 | }, 66 | .list_posts => { 67 | try conn.exec("update gui_scene set current_scene=?", .{@intFromEnum(Scene.listing)}); 68 | }, 69 | .delete_post => { 70 | try sql.execNoArgs(conn, std.fmt.comptimePrint( 71 | "insert into gui_modal(kind) values({d})", 72 | .{@intFromEnum(Modal.confirm_post_deletion)}, 73 | )); 74 | }, 75 | .delete_post_yes => |post_id| { 76 | try sql.exec(conn, "delete from post where id=?", .{post_id}); 77 | try sql.exec(conn, "update gui_scene set current_scene=?", .{@intFromEnum(Scene.listing)}); 78 | try sql.execNoArgs(conn, std.fmt.comptimePrint( 79 | "delete from gui_modal where kind={d}", 80 | .{@intFromEnum(Modal.confirm_post_deletion)}, 81 | )); 82 | try queries.setStatusText( 83 | arena, 84 | conn, 85 | "Deleted post #{d}.", 86 | .{post_id}, 87 | ); 88 | }, 89 | .delete_post_no => { 90 | try sql.execNoArgs(conn, std.fmt.comptimePrint( 91 | "delete from gui_modal where kind={d}", 92 | .{@intFromEnum(Modal.confirm_post_deletion)}, 93 | )); 94 | }, 95 | .add_attachments => |payload| { 96 | for (payload.file_paths) |path| { 97 | const blob = try blobstore.store(arena, path); 98 | 99 | try sql.exec( 100 | conn, 101 | \\insert into attachment (post_id, name, hash, size_bytes) values (?,?,?,?) 102 | \\on conflict (post_id, name) 103 | \\ do update set hash=?, size_bytes=? 104 | , 105 | .{ 106 | payload.post_id, 107 | std.fs.path.basename(path), 108 | &blob.hash, 109 | blob.size, 110 | &blob.hash, 111 | blob.size, 112 | }, 113 | ); 114 | } 115 | try queries.setStatusText( 116 | arena, 117 | conn, 118 | "Added {d} attachment{s}.", 119 | .{ 120 | payload.file_paths.len, 121 | if (payload.file_paths.len > 1) "s" else "", 122 | }, 123 | ); 124 | }, 125 | .delete_selected_attachments => |post_id| { 126 | try sql.exec( 127 | conn, 128 | \\delete from attachment 129 | \\where post_id = ? 130 | \\ and id in (select id from gui_attachment_selected) 131 | , 132 | .{post_id}, 133 | ); 134 | try queries.setStatusText(arena, conn, "Deleted selected attachment(s).", .{}); 135 | }, 136 | .select_attachment => |id| { 137 | try sql.exec( 138 | conn, 139 | "insert into gui_attachment_selected (id) values (?)", 140 | .{id}, 141 | ); 142 | }, 143 | .deselect_attachment => |id| { 144 | try sql.exec( 145 | conn, 146 | "delete from gui_attachment_selected where id=?", 147 | .{id}, 148 | ); 149 | }, 150 | } 151 | 152 | if (!skip_history) { 153 | try history.addUndoBarrier(action, conn); 154 | } 155 | 156 | try conn.commit(); 157 | } 158 | }; 159 | 160 | pub const ActionEnum = enum(i64) { 161 | create_post = 0, 162 | update_post_title = 1, 163 | update_post_slug = 2, 164 | update_post_content = 3, 165 | edit_post = 4, 166 | list_posts = 5, 167 | delete_post = 6, 168 | delete_post_yes = 7, 169 | delete_post_no = 8, 170 | add_attachments = 9, 171 | delete_selected_attachments = 10, 172 | select_attachment = 11, 173 | deselect_attachment = 12, 174 | }; 175 | 176 | pub const Action = union(ActionEnum) { 177 | create_post: void, 178 | update_post_title: struct { id: i64, title: []const u8 }, 179 | update_post_slug: struct { id: i64, slug: []const u8 }, 180 | update_post_content: struct { id: i64, content: []const u8 }, 181 | edit_post: i64, 182 | list_posts: void, 183 | delete_post: i64, 184 | delete_post_yes: i64, 185 | delete_post_no: i64, 186 | add_attachments: struct { post_id: i64, file_paths: []const []const u8 }, 187 | delete_selected_attachments: i64, 188 | select_attachment: i64, 189 | deselect_attachment: i64, 190 | }; 191 | 192 | const Post = struct { 193 | id: i64, 194 | title: []u8, 195 | slug: []u8, 196 | content: []u8, 197 | }; 198 | 199 | pub const Scene = enum(i64) { 200 | listing = 0, 201 | editing = 1, 202 | }; 203 | 204 | pub const PostErrors = struct { 205 | empty_title: bool, 206 | empty_slug: bool, 207 | empty_content: bool, 208 | duplicate_slug: bool, 209 | 210 | pub fn hasErrors(self: PostErrors) bool { 211 | inline for (@typeInfo(PostErrors).@"struct".fields) |field| { 212 | if (@field(self, field.name)) return true; 213 | } 214 | return false; 215 | } 216 | }; 217 | 218 | const Attachment = struct { 219 | id: i64, 220 | name: []const u8, 221 | size: []const u8, 222 | selected: bool, 223 | }; 224 | 225 | const SceneState = union(Scene) { 226 | listing: struct { 227 | posts: []Post, 228 | }, 229 | editing: struct { 230 | post: Post, 231 | post_errors: PostErrors, 232 | show_confirm_delete: bool, 233 | attachments: []Attachment, 234 | }, 235 | }; 236 | 237 | pub const Modal = enum(i64) { 238 | confirm_post_deletion = 0, 239 | }; 240 | 241 | pub const GuiState = union(enum) { 242 | no_file_opened: void, 243 | opened: struct { 244 | scene: SceneState, 245 | status_text: []const u8, 246 | history: struct { 247 | undos: []history.Barrier, 248 | redos: []history.Barrier, 249 | }, 250 | }, 251 | 252 | pub fn read(maybe_conn: ?zqlite.Conn, arena: std.mem.Allocator) !GuiState { 253 | const conn = maybe_conn orelse return .no_file_opened; 254 | 255 | const current_scene: Scene = @enumFromInt( 256 | try sql.selectInt(conn, "select current_scene from gui_scene"), 257 | ); 258 | 259 | const scene: SceneState = switch (current_scene) { 260 | .listing => blk: { 261 | var posts = std.ArrayList(Post).init(arena); 262 | var rows = try sql.rows(conn, "select id, title, slug, content from post order by id desc", .{}); 263 | defer rows.deinit(); 264 | while (rows.next()) |row| { 265 | const post = Post{ 266 | .id = row.int(0), 267 | .title = try arena.dupe(u8, row.text(1)), 268 | .slug = try arena.dupe(u8, row.text(2)), 269 | .content = try arena.dupe(u8, row.text(3)), 270 | }; 271 | try posts.append(post); 272 | } 273 | try sql.check(rows.err, conn); 274 | 275 | break :blk .{ .listing = .{ .posts = posts.items } }; 276 | }, 277 | 278 | .editing => blk: { 279 | var row = (try sql.selectRow(conn, 280 | \\select p.id, p.title, p.slug, p.content 281 | \\from post p 282 | \\inner join gui_scene_editing e on e.post_id = p.id 283 | , .{})).?; 284 | defer row.deinit(); 285 | 286 | const post = Post{ 287 | .id = row.int(0), 288 | .title = try arena.dupe(u8, row.text(1)), 289 | .slug = try arena.dupe(u8, row.text(2)), 290 | .content = try arena.dupe(u8, row.text(3)), 291 | }; 292 | 293 | var err_row = (try sql.selectRow(conn, 294 | \\select 295 | \\ empty_title, 296 | \\ empty_slug, 297 | \\ empty_content, 298 | \\ duplicate_slug 299 | \\from gui_current_post_err 300 | , .{})).?; 301 | defer err_row.deinit(); 302 | 303 | const show_confirm_delete = (try sql.selectInt( 304 | conn, 305 | std.fmt.comptimePrint( 306 | "select exists (select * from gui_modal where kind = {d})", 307 | .{@intFromEnum(Modal.confirm_post_deletion)}, 308 | ), 309 | ) == 1); 310 | 311 | var attachment_rows = try sql.rows( 312 | conn, 313 | \\select a.id, a.name, s.id is not null, size_bytes 314 | \\from attachment a 315 | \\ left outer join gui_attachment_selected s on s.id = a.id 316 | \\where post_id = ? 317 | \\order by a.id 318 | , 319 | .{post.id}, 320 | ); 321 | var attachments: std.ArrayList(Attachment) = .init(arena); 322 | while (attachment_rows.next()) |arow| { 323 | try attachments.append(Attachment{ 324 | .id = arow.int(0), 325 | .name = try arena.dupe(u8, arow.text(1)), 326 | .selected = arow.boolean(2), 327 | .size = try maths.humanReadableSize(arena, arow.int(3)), 328 | }); 329 | } 330 | try sql.check(attachment_rows.err, conn); 331 | 332 | break :blk .{ 333 | .editing = .{ 334 | .post = post, 335 | .post_errors = .{ 336 | .empty_title = err_row.boolean(0), 337 | .empty_slug = err_row.boolean(1), 338 | .empty_content = err_row.boolean(2), 339 | .duplicate_slug = err_row.boolean(3), 340 | }, 341 | .show_confirm_delete = show_confirm_delete, 342 | .attachments = attachments.items, 343 | }, 344 | }; 345 | }, 346 | }; 347 | 348 | const status_text_row = try sql.selectRow( 349 | conn, 350 | "select status_text from gui_status_text where expires_at > datetime('now')", 351 | .{}, 352 | ); 353 | const status_text = if (status_text_row) |row| blk: { 354 | defer row.deinit(); 355 | break :blk try arena.dupe(u8, row.text(0)); 356 | } else ""; 357 | 358 | return .{ 359 | .opened = .{ 360 | .scene = scene, 361 | .status_text = status_text, 362 | .history = .{ 363 | .undos = try history.getBarriers(history.Undo, conn, arena), 364 | .redos = try history.getBarriers(history.Redo, conn, arena), 365 | }, 366 | }, 367 | }; 368 | } 369 | }; 370 | -------------------------------------------------------------------------------- /src/db_schema.sql: -------------------------------------------------------------------------------- 1 | pragma foreign_keys = on; 2 | pragma user_version = 1; 3 | 4 | create table post ( 5 | id integer primary key, 6 | slug text not null default '', 7 | title text not null default '', 8 | content text not null default '' 9 | ); 10 | 11 | create table attachment ( 12 | id integer primary key, 13 | post_id integer not null, 14 | name text not null, 15 | hash text not null check (length(hash) = 64), 16 | size_bytes integer not null check (size_bytes >= 0), 17 | foreign key (post_id) references post (id) on delete cascade, 18 | unique(post_id, name) 19 | ); 20 | 21 | -- Clean up on-disk blob file when the last attachment pointing 22 | -- to it is deleted AND its hash is no longer found in undo/redo stacks. 23 | create trigger clean_up_orphaned_blob_on_delete 24 | after delete on attachment 25 | when not exists ( 26 | select * from attachment where hash=old.hash and id != old.id limit 1 27 | ) and not exists ( 28 | select * from history_undo where statement like concat('%', old.hash, '%') limit 1 29 | ) and not exists ( 30 | select * from history_redo where statement like concat('%', old.hash, '%') limit 1 31 | ) 32 | begin 33 | select blobstore_delete(old.hash); 34 | end; 35 | 36 | -- Same as above but on update. Sqlite doesn't allow using 1 trigger for 37 | -- multiple actions. 38 | -- TODO: It's probably cleaner to implement cleanup logic on application level 39 | -- so that we don't have to use an application-defined sqlite function, which 40 | -- would make it possible to manipulate the db with generic sqlite clients. 41 | create trigger clean_up_orphaned_blob_on_update 42 | after update of hash on attachment 43 | when new.hash != old.hash and not exists ( 44 | select * from attachment where hash=old.hash and id != old.id limit 1 45 | ) and not exists ( 46 | select * from history_undo where statement like concat('%', old.hash, '%') limit 1 47 | ) and not exists ( 48 | select * from history_redo where statement like concat('%', old.hash, '%') limit 1 49 | ) 50 | begin 51 | select blobstore_delete(old.hash); 52 | end; 53 | 54 | create table gui_attachment_selected ( 55 | id integer primary key, 56 | foreign key (id) references attachment (id) on delete cascade 57 | ); 58 | 59 | create table gui_scene ( 60 | id integer primary key check (id = 0) default 0, 61 | current_scene integer default 0 62 | ); 63 | insert into gui_scene (id) values (0); 64 | 65 | create table gui_scene_editing ( 66 | id integer primary key check (id = 0) default 0, 67 | post_id integer default null, 68 | foreign key (post_id) references post (id) on delete set null 69 | ); 70 | insert into gui_scene_editing (id) values (0); 71 | 72 | create view gui_current_post_err as 73 | select 74 | p.id as post_id, 75 | p.title == '' as empty_title, 76 | p.slug == '' as empty_slug, 77 | p.content == '' as empty_content, 78 | exists(select 1 from post p1 where p1.slug = p.slug and p1.id <> p.id) as duplicate_slug 79 | from post p 80 | inner join gui_scene_editing e on e.post_id = p.id; 81 | 82 | create table gui_modal ( 83 | id integer primary key check (id = 1), 84 | kind integer not null 85 | ); 86 | 87 | create table history_undo ( 88 | id integer primary key autoincrement, 89 | statement text not null check (statement <> ''), 90 | barrier_id integer, 91 | 92 | foreign key (barrier_id) references history_barrier_undo (id) on delete cascade 93 | ); 94 | create table history_barrier_undo ( 95 | id integer primary key autoincrement, 96 | action integer not null, 97 | undone boolean not null default false, 98 | created_at text not null default (datetime('now')) 99 | ); 100 | 101 | create table history_redo ( 102 | id integer primary key autoincrement, 103 | statement text not null check (statement <> ''), 104 | barrier_id integer, 105 | foreign key (barrier_id) references history_barrier_redo (id) on delete cascade 106 | ); 107 | -- A redo is either created or deleted, never undone. 108 | -- The `undone` column is only there to stay compatible with the undo tables so 109 | -- we can use the same code on both of them. 110 | create table history_barrier_redo ( 111 | id integer primary key autoincrement, 112 | action integer not null, 113 | undone boolean not null default false check (undone = false), 114 | created_at text not null default (datetime('now')) 115 | ); 116 | 117 | -- TODO remove seed data 118 | insert into post (slug, title, content) values 119 | ('first', 'First!', 'This is my first post.'), 120 | ('second', 'Second post', 'Hello I''m written in [djot](https://djot.net/). 121 | 122 | How''s your _day_ going?') 123 | ; 124 | 125 | create table history_enable_triggers ( 126 | id integer primary key check (id=0), 127 | undo boolean not null default true, 128 | redo boolean not null default false 129 | ); 130 | insert into history_enable_triggers(id) values (0); 131 | 132 | create table gui_status_text ( 133 | id integer primary key check (id = 0) default 0, 134 | status_text text default '', 135 | expires_at text default (datetime('now')) 136 | ); 137 | insert into gui_status_text (id) values (0); 138 | -------------------------------------------------------------------------------- /src/djot.license: -------------------------------------------------------------------------------- 1 | This is the license for djot.lua: https://github.com/jgm/djot.lua 2 | 3 | Copyright (C) 2022 John MacFarlane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/djot.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const println = @import("util.zig").println; 3 | const lua_wrapper = @import("lua_wrapper"); 4 | const djot_lua = @embedFile("djot.lua"); 5 | 6 | var lua: *lua_wrapper.Lua = undefined; 7 | var lua_mut = std.Thread.Mutex{}; 8 | 9 | /// Initialize the lua VM and load the necessary djot.lua library code. 10 | /// Remember to call deinit() when you're all done. 11 | pub fn init(gpa: std.mem.Allocator) !void { 12 | lua = try lua_wrapper.Lua.init(gpa); 13 | 14 | // load lua standard libraries 15 | lua.openLibs(); 16 | 17 | // load the djot.lua amalgamation 18 | lua.doString(djot_lua) catch |err| { 19 | println("lua error: {s}", .{try lua.toString(-1)}); 20 | return err; 21 | }; 22 | 23 | // define simple helper function 24 | lua.doString( 25 | \\djot = require("djot") 26 | \\function djotToHtml(input) 27 | \\ return djot.render_html(djot.parse(input)) 28 | \\end 29 | ) catch |err| { 30 | println("lua error: {s}", .{try lua.toString(-1)}); 31 | return err; 32 | }; 33 | } 34 | 35 | /// Run this when you're all done, to cleanly destroy the lua VM. 36 | pub fn deinit() void { 37 | lua.deinit(); 38 | } 39 | 40 | /// The returned string is owned by the caller. 41 | /// This function is thread-safe. 42 | pub fn toHtml(gpa: std.mem.Allocator, input: []const u8) ![]const u8 { 43 | lua_mut.lock(); 44 | defer lua_mut.unlock(); 45 | 46 | // call the global djotToHtml function 47 | _ = try lua.getGlobal("djotToHtml"); 48 | _ = lua.pushString(input); 49 | lua.protectedCall(.{ .args = 1, .results = 1 }) catch |err| { 50 | println("lua error: {s}", .{try lua.toString(-1)}); 51 | return err; 52 | }; 53 | 54 | const result = gpa.dupe(u8, try lua.toString(1)); 55 | 56 | // All done. Pop previous result from stack. 57 | lua.pop(1); 58 | 59 | return result; 60 | } 61 | -------------------------------------------------------------------------------- /src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhanb/webmaker2000/8cec57b115bd28379312e708c10e3632598abc1d/src/favicon.png -------------------------------------------------------------------------------- /src/fonts/NotoSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhanb/webmaker2000/8cec57b115bd28379312e708c10e3632598abc1d/src/fonts/NotoSans-Bold.ttf -------------------------------------------------------------------------------- /src/fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nhanb/webmaker2000/8cec57b115bd28379312e708c10e3632598abc1d/src/fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /src/fonts/license.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 The Noto Project Authors 2 | (https://github.com/notofonts/latin-greek-cyrillic) 3 | 4 | This Font Software is licensed under the SIL Open Font License, Version 1.1 . 5 | This license is copied below, and is also available with a FAQ at: 6 | https://openfontlicense.org 7 | 8 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 9 | 10 | PREAMBLE 11 | 12 | The goals of the Open Font License (OFL) are to stimulate worldwide development 13 | of collaborative font projects, to support the font creation efforts of academic 14 | and linguistic communities, and to provide a free and open framework in which 15 | fonts may be shared and improved in partnership with others. 16 | 17 | The OFL allows the licensed fonts to be used, studied, modified and 18 | redistributed freely as long as they are not sold by themselves. The fonts, 19 | including any derivative works, can be bundled, embedded, redistributed and/or 20 | sold with any software provided that any reserved names are not used by 21 | derivative works. The fonts and derivatives, however, cannot be released under 22 | any other type of license. The requirement for fonts to remain under this 23 | license does not apply to any document created using the fonts or their 24 | derivatives. 25 | 26 | DEFINITIONS 27 | 28 | "Font Software" refers to the set of files released by the Copyright Holder(s) 29 | under this license and clearly marked as such. This may include source files, 30 | build scripts and documentation. 31 | 32 | "Reserved Font Name" refers to any names specified as such after the copyright 33 | statement(s). "Original Version" refers to the collection of Font Software 34 | components as distributed by the Copyright Holder(s). 35 | 36 | "Modified Version" refers to any derivative made by adding to, deleting, or 37 | substituting -- in part or in whole -- any of the components of the Original 38 | Version, by changing formats or by porting the Font Software to a new 39 | environment. 40 | 41 | "Author" refers to any designer, engineer, programmer, technical writer or other 42 | person who contributed to the Font Software. PERMISSION & CONDITIONS 43 | 44 | Permission is hereby granted, free of charge, to any person obtaining a copy of 45 | the Font Software, to use, study, copy, merge, embed, modify, redistribute, and 46 | sell modified and unmodified copies of the Font Software, subject to the 47 | following conditions: 48 | 49 | Neither the Font Software nor any of its individual components, in Original 50 | or Modified Versions, may be sold by itself. Original or Modified Versions 51 | of the Font Software may be bundled, redistributed and/or sold with any 52 | software, provided that each copy contains the above copyright notice and 53 | this license. These can be included either as stand-alone text files, 54 | human-readable headers or in the appropriate machine-readable metadata 55 | fields within text or binary files as long as those fields can be easily 56 | viewed by the user. No Modified Version of the Font Software may use the 57 | Reserved Font Name(s) unless explicit written permission is granted by the 58 | corresponding Copyright Holder. This restriction only applies to the primary 59 | font name as presented to the users. The name(s) of the Copyright Holder(s) 60 | or the Author(s) of the Font Software shall not be used to promote, endorse 61 | or advertise any Modified Version, except to acknowledge the contribution(s) 62 | of the Copyright Holder(s) and the Author(s) or with their explicit written 63 | permission. The Font Software, modified or unmodified, in part or in whole, 64 | must be distributed entirely under this license, and must not be distributed 65 | under any other license. The requirement for fonts to remain under this 66 | license does not apply to any document created using the Font Software. 67 | 68 | TERMINATION 69 | 70 | This license becomes null and void if any of the above conditions are not met. 71 | 72 | DISCLAIMER 73 | 74 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 75 | IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS 76 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR 77 | OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, 78 | DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, 79 | INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR 80 | OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR 81 | FROM OTHER DEALINGS IN THE FONT SOFTWARE. 82 | -------------------------------------------------------------------------------- /src/history.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zqlite = @import("zqlite"); 3 | const sql = @import("sql.zig"); 4 | const queries = @import("queries.zig"); 5 | const println = @import("util.zig").println; 6 | const core = @import("core.zig"); 7 | 8 | pub const Barrier = struct { 9 | id: i64, 10 | action: core.ActionEnum, 11 | }; 12 | 13 | pub fn shouldSkip(action: core.ActionEnum) bool { 14 | return switch (action) { 15 | .create_post => false, 16 | .update_post_title => false, 17 | .update_post_slug => false, 18 | .update_post_content => false, 19 | .edit_post => false, 20 | .list_posts => false, 21 | .delete_post => true, 22 | .delete_post_yes => false, 23 | .delete_post_no => true, 24 | .add_attachments => false, 25 | .delete_selected_attachments => false, 26 | .select_attachment => false, 27 | .deselect_attachment => false, 28 | }; 29 | } 30 | 31 | const HISTORY_TABLES = &.{ 32 | "post", 33 | "gui_scene", 34 | "gui_scene_editing", 35 | "gui_modal", 36 | "gui_status_text", 37 | "attachment", 38 | "gui_attachment_selected", 39 | }; 40 | 41 | const HistoryType = struct { 42 | main_table: []const u8, 43 | barriers_table: []const u8, 44 | trigger_prefix: []const u8, 45 | enable_triggers_column: []const u8, 46 | }; 47 | pub const Undo = HistoryType{ 48 | .main_table = "history_undo", 49 | .barriers_table = "history_barrier_undo", 50 | .trigger_prefix = "history_trigger_undo", 51 | .enable_triggers_column = "undo", 52 | }; 53 | pub const Redo = HistoryType{ 54 | .main_table = "history_redo", 55 | .barriers_table = "history_barrier_redo", 56 | .trigger_prefix = "history_trigger_redo", 57 | .enable_triggers_column = "redo", 58 | }; 59 | 60 | pub fn createTriggers( 61 | comptime htype: HistoryType, 62 | conn: zqlite.Conn, 63 | arena: std.mem.Allocator, 64 | ) !void { 65 | var timer = try std.time.Timer.start(); 66 | defer println("** createTriggers() took {}ms", .{timer.read() / 1_000_000}); 67 | 68 | inline for (HISTORY_TABLES) |table| { 69 | // First colect this table's column names 70 | var column_names = std.ArrayList([]const u8).init(arena); 71 | 72 | var records = try sql.rows( 73 | conn, 74 | std.fmt.comptimePrint("pragma table_info({s})", .{table}), 75 | .{}, 76 | ); 77 | defer records.deinit(); 78 | 79 | while (records.next()) |row| { 80 | const column = try arena.dupe(u8, row.text(1)); 81 | try column_names.append(column); 82 | } 83 | try sql.check(records.err, conn); 84 | 85 | // Now create triggers for all 3 events: 86 | 87 | // INSERT: 88 | const insert_trigger = std.fmt.comptimePrint( 89 | \\CREATE TRIGGER {s}_{s}_insert AFTER INSERT ON {s} 90 | \\WHEN (select {s} from history_enable_triggers limit 1) 91 | \\BEGIN 92 | \\ INSERT INTO {s} (statement) VALUES ( 93 | \\ 'DELETE FROM {s} WHERE id=' || new.id 94 | \\ ); 95 | \\END; 96 | , .{ 97 | htype.trigger_prefix, 98 | table, 99 | table, 100 | htype.enable_triggers_column, 101 | htype.main_table, 102 | table, 103 | }); 104 | try sql.execNoArgs(conn, insert_trigger); 105 | 106 | // UPDATE: 107 | var column_updates = std.ArrayList(u8).init(arena); 108 | for (column_names.items, 0..) |col, i| { 109 | if (i != 0) { 110 | try column_updates.appendSlice(", ' || '"); 111 | } 112 | try column_updates.appendSlice(try std.fmt.allocPrint( 113 | arena, 114 | "{s}=' || quote(old.{s}) ||'", 115 | .{ col, col }, 116 | )); 117 | } 118 | 119 | const update_trigger = try std.fmt.allocPrint( 120 | arena, 121 | \\CREATE TRIGGER {s}_{s}_update AFTER UPDATE ON {s} 122 | \\WHEN (select {s} from history_enable_triggers limit 1) 123 | \\BEGIN 124 | \\ INSERT INTO {s} (statement) VALUES ( 125 | \\ 'UPDATE {s} SET {s} WHERE id=' || old.id 126 | \\ ); 127 | \\END; 128 | , 129 | .{ 130 | htype.trigger_prefix, 131 | table, 132 | table, 133 | htype.enable_triggers_column, 134 | htype.main_table, 135 | table, 136 | column_updates.items, 137 | }, 138 | ); 139 | try sql.exec(conn, update_trigger, .{}); 140 | 141 | // DELETE: 142 | var reinsert_values = std.ArrayList(u8).init(arena); 143 | for (column_names.items, 0..) |col, i| { 144 | if (i != 0) { 145 | try reinsert_values.appendSlice(", ' || '"); 146 | } 147 | try reinsert_values.appendSlice(try std.fmt.allocPrint( 148 | arena, 149 | "' || quote(old.{s}) ||'", 150 | .{col}, 151 | )); 152 | } 153 | 154 | const delete_trigger = try std.fmt.allocPrint( 155 | arena, 156 | \\CREATE TRIGGER {s}_{s}_delete AFTER DELETE ON {s} 157 | \\WHEN (select {s} from history_enable_triggers limit 1) 158 | \\BEGIN 159 | \\ INSERT INTO {s} (statement) VALUES ( 160 | \\ 'INSERT INTO {s} ({s}) VALUES ({s})' 161 | \\ ); 162 | \\END; 163 | , 164 | .{ 165 | htype.trigger_prefix, 166 | table, 167 | table, 168 | htype.enable_triggers_column, 169 | htype.main_table, 170 | table, 171 | try std.mem.join(arena, ",", column_names.items), 172 | reinsert_values.items, 173 | }, 174 | ); 175 | try sql.exec(conn, delete_trigger, .{}); 176 | } 177 | } 178 | 179 | pub fn undo( 180 | conn: zqlite.Conn, 181 | barriers: []Barrier, 182 | ) !void { 183 | if (barriers.len == 0) return; 184 | 185 | var timer = try std.time.Timer.start(); 186 | defer println("undo: took {}ms", .{timer.read() / 1_000_000}); 187 | 188 | try disableUndoTriggers(conn); 189 | try enableRedoTriggers(conn); 190 | 191 | // First find all undo records in this barrier, 192 | // execute and flag them as undone: 193 | 194 | var undo_rows = try sql.rows( 195 | conn, 196 | \\select statement from history_undo 197 | \\where barrier_id = ? 198 | \\order by id desc 199 | , 200 | .{barriers[0].id}, 201 | ); 202 | defer undo_rows.deinit(); 203 | 204 | while (undo_rows.next()) |row| { 205 | const sql_stmt = row.text(0); 206 | try sql.exec(conn, sql_stmt, .{}); 207 | } 208 | try sql.check(undo_rows.err, conn); 209 | 210 | // Then flag the barrier itself as undone: 211 | try sql.exec( 212 | conn, 213 | std.fmt.comptimePrint( 214 | "update {s} set undone=true where id=?", 215 | .{Undo.barriers_table}, 216 | ), 217 | .{barriers[0].id}, 218 | ); 219 | 220 | try sql.exec( 221 | conn, 222 | "insert into history_barrier_redo (action) values (?)", 223 | .{@intFromEnum(barriers[0].action)}, 224 | ); 225 | 226 | const redo_barrier_id = conn.lastInsertedRowId(); 227 | try sql.exec( 228 | conn, 229 | "update history_redo set barrier_id = ? where barrier_id is null", 230 | .{redo_barrier_id}, 231 | ); 232 | 233 | try disableRedoTriggers(conn); 234 | try enableUndoTriggers(conn); 235 | 236 | const status_text = switch (barriers[0].action) { 237 | .create_post => "Undo: Create post.", 238 | .update_post_title => "Undo: Update post title.", 239 | .update_post_slug => "Undo: Update post slug.", 240 | .update_post_content => "Undo: Update post content.", 241 | .edit_post => "Undo: View post.", 242 | .list_posts => "Undo: View posts list.", 243 | .delete_post => unreachable, 244 | .delete_post_yes => "Undo: Delete post.", 245 | .delete_post_no => unreachable, 246 | .add_attachments => "Undo: Add attachments.", 247 | .delete_selected_attachments => "Undo: Delete attachments.", 248 | .select_attachment => "Undo: Select attachment.", 249 | .deselect_attachment => "Undo: Deselect attachment.", 250 | }; 251 | try queries.setStatusTextNoAlloc(conn, status_text); 252 | } 253 | 254 | pub fn getBarriers( 255 | comptime htype: HistoryType, 256 | conn: zqlite.Conn, 257 | arena: std.mem.Allocator, 258 | ) ![]Barrier { 259 | var list = std.ArrayList(Barrier).init(arena); 260 | 261 | var rows = try sql.rows( 262 | conn, 263 | std.fmt.comptimePrint( 264 | \\select id, action from {s} 265 | \\where undone is false 266 | \\order by id desc 267 | , .{htype.barriers_table}), 268 | .{}, 269 | ); 270 | defer rows.deinit(); 271 | 272 | while (rows.next()) |row| { 273 | try list.append(.{ 274 | .id = row.int(0), 275 | .action = @enumFromInt(row.int(1)), 276 | }); 277 | } 278 | try sql.check(rows.err, conn); 279 | 280 | return list.items; 281 | } 282 | 283 | // For text input changes, skip creating an undo barrier if this change is 284 | // within a few seconds since the last change. 285 | // This also cleans up trailing history_undo records in such cases. 286 | fn debounceIfNeeded(action: core.ActionEnum, conn: zqlite.Conn) !bool { 287 | const is_debounceable = switch (action) { 288 | .create_post => false, 289 | .update_post_title => true, 290 | .update_post_slug => true, 291 | .update_post_content => true, 292 | .edit_post => false, 293 | .list_posts => false, 294 | .delete_post => unreachable, 295 | .delete_post_yes => false, 296 | .delete_post_no => unreachable, 297 | .add_attachments => false, 298 | .delete_selected_attachments => false, 299 | .select_attachment => false, 300 | .deselect_attachment => false, 301 | }; 302 | if (!is_debounceable) return false; 303 | 304 | var prevRow = try sql.selectRow( 305 | conn, 306 | \\select id 307 | \\from history_barrier_undo 308 | \\where action = ? 309 | \\ and created_at >= datetime('now', '-2 seconds') 310 | \\ and id = (select max(id) from history_barrier_undo) 311 | , 312 | .{@intFromEnum(action)}, 313 | ) orelse return false; 314 | 315 | const prevBarrierId = prevRow.int(0); 316 | try sql.exec( 317 | conn, 318 | "update history_barrier_undo set created_at = datetime('now') where id=?", 319 | .{prevBarrierId}, 320 | ); 321 | 322 | try sql.execNoArgs(conn, "delete from history_undo where barrier_id is null"); 323 | return true; 324 | } 325 | 326 | pub fn addUndoBarrier( 327 | action: core.ActionEnum, 328 | conn: zqlite.Conn, 329 | ) !void { 330 | if (try debounceIfNeeded(action, conn)) { 331 | return; 332 | } 333 | 334 | try sql.exec( 335 | conn, 336 | "insert into history_barrier_undo (action) values (?)", 337 | .{@intFromEnum(action)}, 338 | ); 339 | 340 | const barrier_id = conn.lastInsertedRowId(); 341 | try sql.exec( 342 | conn, 343 | "update history_undo set barrier_id = ? where barrier_id is null", 344 | .{barrier_id}, 345 | ); 346 | } 347 | 348 | pub fn redo( 349 | conn: zqlite.Conn, 350 | barriers: []Barrier, 351 | ) !void { 352 | if (barriers.len == 0) return; 353 | 354 | var timer = try std.time.Timer.start(); 355 | defer println("redo: took {}ms", .{timer.read() / 1_000_000}); 356 | 357 | try disableUndoTriggers(conn); 358 | 359 | var redo_rows = try sql.rows( 360 | conn, 361 | \\select id, statement from history_redo 362 | \\where barrier_id = ? 363 | \\order by id desc 364 | , 365 | .{barriers[0].id}, 366 | ); 367 | defer redo_rows.deinit(); 368 | 369 | while (redo_rows.next()) |row| { 370 | const id = row.text(0); 371 | const sql_stmt = row.text(1); 372 | try sql.exec(conn, sql_stmt, .{}); 373 | try sql.exec(conn, "delete from history_redo where id=?", .{id}); 374 | } 375 | try sql.check(redo_rows.err, conn); 376 | 377 | // disable undone flag on next undo 378 | try sql.exec(conn, 379 | \\update history_barrier_undo 380 | \\set undone = false 381 | \\where id = ( 382 | \\ select min(id) from history_barrier_undo where undone is true 383 | \\) 384 | , .{}); 385 | 386 | try sql.exec( 387 | conn, 388 | std.fmt.comptimePrint( 389 | "delete from {s} where id=?", 390 | .{Redo.barriers_table}, 391 | ), 392 | .{barriers[0].id}, 393 | ); 394 | 395 | try enableUndoTriggers(conn); 396 | 397 | const status_text = switch (barriers[0].action) { 398 | .create_post => "Redo: Create post.", 399 | .update_post_title => "Redo: Update post title.", 400 | .update_post_slug => "Redo: Update post slug.", 401 | .update_post_content => "Redo: Update post content.", 402 | .edit_post => "Redo: View post.", 403 | .list_posts => "Redo: View posts list.", 404 | .delete_post => unreachable, 405 | .delete_post_yes => "Redo: Delete post.", 406 | .delete_post_no => unreachable, 407 | .add_attachments => "Redo: Add attachments.", 408 | .delete_selected_attachments => "Redo: Delete attachments.", 409 | .select_attachment => "Redo: Select attachment.", 410 | .deselect_attachment => "Redo: Deselect attachment.", 411 | }; 412 | try queries.setStatusTextNoAlloc(conn, status_text); 413 | } 414 | 415 | // The heart of emacs-style undo-redo: when the user performs an undo, then 416 | // makes changes, we put all trailing redos into the canonical undo stack, to 417 | // make sure that every previous state is reachable via undo. 418 | pub fn foldRedos(conn: zqlite.Conn, redos: []Barrier) !void { 419 | if (redos.len == 0) return; 420 | 421 | try sql.execNoArgs(conn, "update history_barrier_undo set undone=false;"); 422 | 423 | var redo_barriers = try sql.rows(conn, "select id, action from history_barrier_redo order by id", .{}); 424 | defer redo_barriers.deinit(); 425 | 426 | while (redo_barriers.next()) |barrier| { 427 | const redo_barrier_id = barrier.int(0); 428 | const redo_barrier_action = barrier.text(1); 429 | 430 | try sql.exec( 431 | conn, 432 | "insert into history_barrier_undo (action) values (?)", 433 | .{redo_barrier_action}, 434 | ); 435 | 436 | const undo_barrier_id = conn.lastInsertedRowId(); 437 | 438 | try sql.exec( 439 | conn, 440 | \\insert into history_undo (barrier_id, statement) 441 | \\select ?1, statement from history_redo where barrier_id=?2 order by id 442 | , 443 | .{ undo_barrier_id, redo_barrier_id }, 444 | ); 445 | } 446 | try sql.check(redo_barriers.err, conn); 447 | 448 | try sql.execNoArgs(conn, "delete from history_redo"); 449 | try sql.execNoArgs(conn, "delete from history_barrier_redo"); 450 | } 451 | 452 | fn disableUndoTriggers(conn: zqlite.Conn) !void { 453 | try sql.execNoArgs(conn, "update history_enable_triggers set undo=false"); 454 | } 455 | fn enableUndoTriggers(conn: zqlite.Conn) !void { 456 | try sql.execNoArgs(conn, "update history_enable_triggers set undo=true"); 457 | } 458 | fn disableRedoTriggers(conn: zqlite.Conn) !void { 459 | try sql.execNoArgs(conn, "update history_enable_triggers set redo=false"); 460 | } 461 | fn enableRedoTriggers(conn: zqlite.Conn) !void { 462 | try sql.execNoArgs(conn, "update history_enable_triggers set redo=true"); 463 | } 464 | -------------------------------------------------------------------------------- /src/html.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Allocator = std.mem.Allocator; 3 | const ArrayList = std.ArrayList; 4 | 5 | fn escape(allocator: std.mem.Allocator, attr: []const u8) []const u8 { 6 | var escaped: []u8 = undefined; 7 | var unescaped: []const u8 = attr; 8 | inline for (.{ 9 | "&", 10 | "\"", 11 | "'", 12 | "<", 13 | ">", 14 | }, .{ 15 | "&", 16 | """, 17 | "'", 18 | "<", 19 | ">", 20 | }) |needle, replacement| { 21 | const size = std.mem.replacementSize(u8, unescaped, needle, replacement); 22 | escaped = allocator.alloc(u8, size) catch unreachable; 23 | _ = std.mem.replace(u8, unescaped, needle, replacement, escaped); 24 | unescaped = escaped; 25 | } 26 | return escaped; 27 | } 28 | 29 | pub const Element = struct { 30 | allocator: Allocator, 31 | tag: []const u8, 32 | attrs: []const Attr, 33 | children: ?[]const Child, 34 | 35 | pub fn writeTo(self: Element, writer: anytype) !void { 36 | try writer.writeAll("<"); 37 | try writer.writeAll(self.tag); 38 | 39 | for (self.attrs) |attr| { 40 | try writer.writeAll(" "); 41 | try writer.writeAll(attr.name); 42 | try writer.writeAll("=\""); 43 | try writer.writeAll(escape(self.allocator, attr.value)); 44 | try writer.writeAll("\""); 45 | } 46 | 47 | try writer.writeAll(">"); 48 | 49 | if (self.children) |children| { 50 | for (children) |child| switch (child) { 51 | .text => |text| try writer.writeAll(escape(self.allocator, text)), 52 | .elem => |elem| try elem.writeTo(writer), 53 | }; 54 | try writer.writeAll(""); 57 | } 58 | } 59 | 60 | /// Caller owns result memory 61 | pub fn toHtml(self: Element, arena: std.mem.Allocator) ![]const u8 { 62 | var buf = std.ArrayList(u8).init(arena); 63 | try self.writeTo(buf.writer()); 64 | return buf.items; 65 | } 66 | }; 67 | 68 | pub const Attr = struct { 69 | name: []const u8, 70 | value: []const u8, 71 | }; 72 | 73 | pub const Child = union(enum) { 74 | text: []const u8, 75 | elem: Element, 76 | }; 77 | 78 | test "Element" { 79 | const test_allocator = std.testing.allocator; 80 | var arena = std.heap.ArenaAllocator.init(test_allocator); 81 | defer arena.deinit(); 82 | 83 | const h = Builder{ .allocator = arena.allocator() }; 84 | const doc2 = h.html( 85 | .{ .lang = "en", .style = "font-family: monospace;" }, 86 | .{ 87 | h.head(null, .{h.title(null, .{"My title"})}), 88 | h.body(null, .{ 89 | h.h1(null, .{"Hello!"}), 90 | h.hr(null), 91 | h.p( 92 | .{ .@"escapes<&" = "&<>\"'" }, 93 | .{"Escape me: &<>\"'"}, 94 | ), 95 | }), 96 | }, 97 | ); 98 | 99 | var output = ArrayList(u8).init(arena.allocator()); 100 | var writer = output.writer(); 101 | 102 | try doc2.writeTo(&writer); 103 | 104 | try std.testing.expectEqualStrings( 105 | \\My title

Hello!


Escape me: &<>"'

106 | , 107 | output.items, 108 | ); 109 | } 110 | 111 | pub const Builder = struct { 112 | allocator: Allocator, 113 | 114 | fn element(self: Builder, comptime tag: []const u8, attrs: anytype, children: anytype) Element { 115 | var attrs_list = ArrayList(Attr).init(self.allocator); 116 | 117 | if (@TypeOf(attrs) != @TypeOf(null)) { 118 | inline for (@typeInfo(@TypeOf(attrs)).@"struct".fields) |field| { 119 | comptime { 120 | // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 121 | // Attribute names must consist of one or more characters other than 122 | // controls, U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), 123 | // U+003D (=), and noncharacters. In the HTML syntax, attribute names, even 124 | // those for foreign elements, may be written with any mix of ASCII lower 125 | // and ASCII upper alphas. 126 | for (field.name) |char| { 127 | if (!std.ascii.isASCII(char)) { 128 | unreachable; // found non-ascii char in html attribute name 129 | } 130 | if (std.ascii.isControl(char)) { 131 | unreachable; // found forbidden control char in html attribute name 132 | } 133 | for (" \"'>/=") |forbidden_char| { 134 | if (char == forbidden_char) { 135 | unreachable; // found forbidden char in html attribute name 136 | } 137 | } 138 | } 139 | } 140 | 141 | attrs_list.append(.{ 142 | .name = field.name, 143 | .value = @field(attrs, field.name), 144 | }) catch unreachable; 145 | } 146 | } 147 | 148 | if (@TypeOf(children) == @TypeOf(null)) { 149 | return Element{ 150 | .allocator = self.allocator, 151 | .tag = tag, 152 | .attrs = attrs_list.items, 153 | .children = children, 154 | }; 155 | } 156 | 157 | // Each item in the `children` tuple can be an Element, []Element, []Child, or string. 158 | // This makes composition (read: templates) easier. 159 | var children_list = ArrayList(Child).init(self.allocator); 160 | inline for (children) |child| switch (@TypeOf(child)) { 161 | Element => children_list.append(.{ .elem = child }) catch unreachable, 162 | []Element => { 163 | for (child) |c| { 164 | children_list.append(.{ .elem = c }) catch unreachable; 165 | } 166 | }, 167 | Child => { 168 | children_list.append(child) catch unreachable; 169 | }, 170 | []Child => { 171 | for (child) |c| { 172 | children_list.append(c) catch unreachable; 173 | } 174 | }, 175 | else => children_list.append(.{ .text = @as([]const u8, child) }) catch unreachable, 176 | }; 177 | return Element{ 178 | .allocator = self.allocator, 179 | .tag = tag, 180 | .attrs = attrs_list.items, 181 | .children = children_list.items, 182 | }; 183 | } 184 | 185 | pub fn writeDoctype(_: Builder, writer: anytype) !void { 186 | try writer.writeAll("\n"); 187 | } 188 | 189 | pub fn area(self: Builder, attrs: anytype) Element { 190 | return self.element("area", attrs, null); 191 | } 192 | pub fn base(self: Builder, attrs: anytype) Element { 193 | return self.element("base", attrs, null); 194 | } 195 | pub fn br(self: Builder, attrs: anytype) Element { 196 | return self.element("br", attrs, null); 197 | } 198 | pub fn col(self: Builder, attrs: anytype) Element { 199 | return self.element("col", attrs, null); 200 | } 201 | pub fn embed(self: Builder, attrs: anytype) Element { 202 | return self.element("embed", attrs, null); 203 | } 204 | pub fn hr(self: Builder, attrs: anytype) Element { 205 | return self.element("hr", attrs, null); 206 | } 207 | pub fn img(self: Builder, attrs: anytype) Element { 208 | return self.element("img", attrs, null); 209 | } 210 | pub fn input(self: Builder, attrs: anytype) Element { 211 | return self.element("input", attrs, null); 212 | } 213 | pub fn link(self: Builder, attrs: anytype) Element { 214 | return self.element("link", attrs, null); 215 | } 216 | pub fn meta(self: Builder, attrs: anytype) Element { 217 | return self.element("meta", attrs, null); 218 | } 219 | pub fn source(self: Builder, attrs: anytype) Element { 220 | return self.element("source", attrs, null); 221 | } 222 | pub fn track(self: Builder, attrs: anytype) Element { 223 | return self.element("track", attrs, null); 224 | } 225 | pub fn wbr(self: Builder, attrs: anytype) Element { 226 | return self.element("wbr", attrs, null); 227 | } 228 | pub fn a(self: Builder, attrs: anytype, children: anytype) Element { 229 | return self.element("a", attrs, children); 230 | } 231 | pub fn abbr(self: Builder, attrs: anytype, children: anytype) Element { 232 | return self.element("abbr", attrs, children); 233 | } 234 | pub fn acronym(self: Builder, attrs: anytype, children: anytype) Element { 235 | return self.element("acronym", attrs, children); 236 | } 237 | pub fn address(self: Builder, attrs: anytype, children: anytype) Element { 238 | return self.element("address", attrs, children); 239 | } 240 | pub fn article(self: Builder, attrs: anytype, children: anytype) Element { 241 | return self.element("article", attrs, children); 242 | } 243 | pub fn aside(self: Builder, attrs: anytype, children: anytype) Element { 244 | return self.element("aside", attrs, children); 245 | } 246 | pub fn audio(self: Builder, attrs: anytype, children: anytype) Element { 247 | return self.element("audio", attrs, children); 248 | } 249 | pub fn b(self: Builder, attrs: anytype, children: anytype) Element { 250 | return self.element("b", attrs, children); 251 | } 252 | pub fn bdi(self: Builder, attrs: anytype, children: anytype) Element { 253 | return self.element("bdi", attrs, children); 254 | } 255 | pub fn bdo(self: Builder, attrs: anytype, children: anytype) Element { 256 | return self.element("bdo", attrs, children); 257 | } 258 | pub fn big(self: Builder, attrs: anytype, children: anytype) Element { 259 | return self.element("big", attrs, children); 260 | } 261 | pub fn blockquote(self: Builder, attrs: anytype, children: anytype) Element { 262 | return self.element("blockquote", attrs, children); 263 | } 264 | pub fn body(self: Builder, attrs: anytype, children: anytype) Element { 265 | return self.element("body", attrs, children); 266 | } 267 | pub fn button(self: Builder, attrs: anytype, children: anytype) Element { 268 | return self.element("button", attrs, children); 269 | } 270 | pub fn canvas(self: Builder, attrs: anytype, children: anytype) Element { 271 | return self.element("canvas", attrs, children); 272 | } 273 | pub fn caption(self: Builder, attrs: anytype, children: anytype) Element { 274 | return self.element("caption", attrs, children); 275 | } 276 | pub fn center(self: Builder, attrs: anytype, children: anytype) Element { 277 | return self.element("center", attrs, children); 278 | } 279 | pub fn cite(self: Builder, attrs: anytype, children: anytype) Element { 280 | return self.element("cite", attrs, children); 281 | } 282 | pub fn code(self: Builder, attrs: anytype, children: anytype) Element { 283 | return self.element("code", attrs, children); 284 | } 285 | pub fn colgroup(self: Builder, attrs: anytype, children: anytype) Element { 286 | return self.element("colgroup", attrs, children); 287 | } 288 | pub fn data(self: Builder, attrs: anytype, children: anytype) Element { 289 | return self.element("data", attrs, children); 290 | } 291 | pub fn datalist(self: Builder, attrs: anytype, children: anytype) Element { 292 | return self.element("datalist", attrs, children); 293 | } 294 | pub fn dd(self: Builder, attrs: anytype, children: anytype) Element { 295 | return self.element("dd", attrs, children); 296 | } 297 | pub fn del(self: Builder, attrs: anytype, children: anytype) Element { 298 | return self.element("del", attrs, children); 299 | } 300 | pub fn details(self: Builder, attrs: anytype, children: anytype) Element { 301 | return self.element("details", attrs, children); 302 | } 303 | pub fn dfn(self: Builder, attrs: anytype, children: anytype) Element { 304 | return self.element("dfn", attrs, children); 305 | } 306 | pub fn dialog(self: Builder, attrs: anytype, children: anytype) Element { 307 | return self.element("dialog", attrs, children); 308 | } 309 | pub fn dir(self: Builder, attrs: anytype, children: anytype) Element { 310 | return self.element("dir", attrs, children); 311 | } 312 | pub fn div(self: Builder, attrs: anytype, children: anytype) Element { 313 | return self.element("div", attrs, children); 314 | } 315 | pub fn dl(self: Builder, attrs: anytype, children: anytype) Element { 316 | return self.element("dl", attrs, children); 317 | } 318 | pub fn dt(self: Builder, attrs: anytype, children: anytype) Element { 319 | return self.element("dt", attrs, children); 320 | } 321 | pub fn em(self: Builder, attrs: anytype, children: anytype) Element { 322 | return self.element("em", attrs, children); 323 | } 324 | pub fn fieldset(self: Builder, attrs: anytype, children: anytype) Element { 325 | return self.element("fieldset", attrs, children); 326 | } 327 | pub fn figcaption(self: Builder, attrs: anytype, children: anytype) Element { 328 | return self.element("figcaption", attrs, children); 329 | } 330 | pub fn figure(self: Builder, attrs: anytype, children: anytype) Element { 331 | return self.element("figure", attrs, children); 332 | } 333 | pub fn font(self: Builder, attrs: anytype, children: anytype) Element { 334 | return self.element("font", attrs, children); 335 | } 336 | pub fn footer(self: Builder, attrs: anytype, children: anytype) Element { 337 | return self.element("footer", attrs, children); 338 | } 339 | pub fn form(self: Builder, attrs: anytype, children: anytype) Element { 340 | return self.element("form", attrs, children); 341 | } 342 | pub fn frame(self: Builder, attrs: anytype, children: anytype) Element { 343 | return self.element("frame", attrs, children); 344 | } 345 | pub fn frameset(self: Builder, attrs: anytype, children: anytype) Element { 346 | return self.element("frameset", attrs, children); 347 | } 348 | pub fn h1(self: Builder, attrs: anytype, children: anytype) Element { 349 | return self.element("h1", attrs, children); 350 | } 351 | pub fn head(self: Builder, attrs: anytype, children: anytype) Element { 352 | return self.element("head", attrs, children); 353 | } 354 | pub fn header(self: Builder, attrs: anytype, children: anytype) Element { 355 | return self.element("header", attrs, children); 356 | } 357 | pub fn hgroup(self: Builder, attrs: anytype, children: anytype) Element { 358 | return self.element("hgroup", attrs, children); 359 | } 360 | pub fn html(self: Builder, attrs: anytype, children: anytype) Element { 361 | return self.element("html", attrs, children); 362 | } 363 | pub fn i(self: Builder, attrs: anytype, children: anytype) Element { 364 | return self.element("i", attrs, children); 365 | } 366 | pub fn iframe(self: Builder, attrs: anytype, children: anytype) Element { 367 | return self.element("iframe", attrs, children); 368 | } 369 | pub fn image(self: Builder, attrs: anytype, children: anytype) Element { 370 | return self.element("image", attrs, children); 371 | } 372 | pub fn ins(self: Builder, attrs: anytype, children: anytype) Element { 373 | return self.element("ins", attrs, children); 374 | } 375 | pub fn kbd(self: Builder, attrs: anytype, children: anytype) Element { 376 | return self.element("kbd", attrs, children); 377 | } 378 | pub fn label(self: Builder, attrs: anytype, children: anytype) Element { 379 | return self.element("label", attrs, children); 380 | } 381 | pub fn legend(self: Builder, attrs: anytype, children: anytype) Element { 382 | return self.element("legend", attrs, children); 383 | } 384 | pub fn li(self: Builder, attrs: anytype, children: anytype) Element { 385 | return self.element("li", attrs, children); 386 | } 387 | pub fn main(self: Builder, attrs: anytype, children: anytype) Element { 388 | return self.element("main", attrs, children); 389 | } 390 | pub fn map(self: Builder, attrs: anytype, children: anytype) Element { 391 | return self.element("map", attrs, children); 392 | } 393 | pub fn mark(self: Builder, attrs: anytype, children: anytype) Element { 394 | return self.element("mark", attrs, children); 395 | } 396 | pub fn marquee(self: Builder, attrs: anytype, children: anytype) Element { 397 | return self.element("marquee", attrs, children); 398 | } 399 | pub fn menu(self: Builder, attrs: anytype, children: anytype) Element { 400 | return self.element("menu", attrs, children); 401 | } 402 | pub fn menuitem(self: Builder, attrs: anytype, children: anytype) Element { 403 | return self.element("menuitem", attrs, children); 404 | } 405 | pub fn meter(self: Builder, attrs: anytype, children: anytype) Element { 406 | return self.element("meter", attrs, children); 407 | } 408 | pub fn nav(self: Builder, attrs: anytype, children: anytype) Element { 409 | return self.element("nav", attrs, children); 410 | } 411 | pub fn nobr(self: Builder, attrs: anytype, children: anytype) Element { 412 | return self.element("nobr", attrs, children); 413 | } 414 | pub fn noembed(self: Builder, attrs: anytype, children: anytype) Element { 415 | return self.element("noembed", attrs, children); 416 | } 417 | pub fn noframes(self: Builder, attrs: anytype, children: anytype) Element { 418 | return self.element("noframes", attrs, children); 419 | } 420 | pub fn noscript(self: Builder, attrs: anytype, children: anytype) Element { 421 | return self.element("noscript", attrs, children); 422 | } 423 | pub fn object(self: Builder, attrs: anytype, children: anytype) Element { 424 | return self.element("object", attrs, children); 425 | } 426 | pub fn ol(self: Builder, attrs: anytype, children: anytype) Element { 427 | return self.element("ol", attrs, children); 428 | } 429 | pub fn optgroup(self: Builder, attrs: anytype, children: anytype) Element { 430 | return self.element("optgroup", attrs, children); 431 | } 432 | pub fn option(self: Builder, attrs: anytype, children: anytype) Element { 433 | return self.element("option", attrs, children); 434 | } 435 | pub fn output(self: Builder, attrs: anytype, children: anytype) Element { 436 | return self.element("output", attrs, children); 437 | } 438 | pub fn p(self: Builder, attrs: anytype, children: anytype) Element { 439 | return self.element("p", attrs, children); 440 | } 441 | pub fn param(self: Builder, attrs: anytype, children: anytype) Element { 442 | return self.element("param", attrs, children); 443 | } 444 | pub fn picture(self: Builder, attrs: anytype, children: anytype) Element { 445 | return self.element("picture", attrs, children); 446 | } 447 | pub fn plaintext(self: Builder, attrs: anytype, children: anytype) Element { 448 | return self.element("plaintext", attrs, children); 449 | } 450 | pub fn portal(self: Builder, attrs: anytype, children: anytype) Element { 451 | return self.element("portal", attrs, children); 452 | } 453 | pub fn pre(self: Builder, attrs: anytype, children: anytype) Element { 454 | return self.element("pre", attrs, children); 455 | } 456 | pub fn progress(self: Builder, attrs: anytype, children: anytype) Element { 457 | return self.element("progress", attrs, children); 458 | } 459 | pub fn q(self: Builder, attrs: anytype, children: anytype) Element { 460 | return self.element("q", attrs, children); 461 | } 462 | pub fn rb(self: Builder, attrs: anytype, children: anytype) Element { 463 | return self.element("rb", attrs, children); 464 | } 465 | pub fn rp(self: Builder, attrs: anytype, children: anytype) Element { 466 | return self.element("rp", attrs, children); 467 | } 468 | pub fn rt(self: Builder, attrs: anytype, children: anytype) Element { 469 | return self.element("rt", attrs, children); 470 | } 471 | pub fn rtc(self: Builder, attrs: anytype, children: anytype) Element { 472 | return self.element("rtc", attrs, children); 473 | } 474 | pub fn ruby(self: Builder, attrs: anytype, children: anytype) Element { 475 | return self.element("ruby", attrs, children); 476 | } 477 | pub fn s(self: Builder, attrs: anytype, children: anytype) Element { 478 | return self.element("s", attrs, children); 479 | } 480 | pub fn samp(self: Builder, attrs: anytype, children: anytype) Element { 481 | return self.element("samp", attrs, children); 482 | } 483 | pub fn script(self: Builder, attrs: anytype, children: anytype) Element { 484 | return self.element("script", attrs, children); 485 | } 486 | pub fn search(self: Builder, attrs: anytype, children: anytype) Element { 487 | return self.element("search", attrs, children); 488 | } 489 | pub fn section(self: Builder, attrs: anytype, children: anytype) Element { 490 | return self.element("section", attrs, children); 491 | } 492 | pub fn select(self: Builder, attrs: anytype, children: anytype) Element { 493 | return self.element("select", attrs, children); 494 | } 495 | pub fn slot(self: Builder, attrs: anytype, children: anytype) Element { 496 | return self.element("slot", attrs, children); 497 | } 498 | pub fn small(self: Builder, attrs: anytype, children: anytype) Element { 499 | return self.element("small", attrs, children); 500 | } 501 | pub fn span(self: Builder, attrs: anytype, children: anytype) Element { 502 | return self.element("span", attrs, children); 503 | } 504 | pub fn strike(self: Builder, attrs: anytype, children: anytype) Element { 505 | return self.element("strike", attrs, children); 506 | } 507 | pub fn strong(self: Builder, attrs: anytype, children: anytype) Element { 508 | return self.element("strong", attrs, children); 509 | } 510 | pub fn style(self: Builder, attrs: anytype, children: anytype) Element { 511 | return self.element("style", attrs, children); 512 | } 513 | pub fn sub(self: Builder, attrs: anytype, children: anytype) Element { 514 | return self.element("sub", attrs, children); 515 | } 516 | pub fn summary(self: Builder, attrs: anytype, children: anytype) Element { 517 | return self.element("summary", attrs, children); 518 | } 519 | pub fn sup(self: Builder, attrs: anytype, children: anytype) Element { 520 | return self.element("sup", attrs, children); 521 | } 522 | pub fn table(self: Builder, attrs: anytype, children: anytype) Element { 523 | return self.element("table", attrs, children); 524 | } 525 | pub fn tbody(self: Builder, attrs: anytype, children: anytype) Element { 526 | return self.element("tbody", attrs, children); 527 | } 528 | pub fn td(self: Builder, attrs: anytype, children: anytype) Element { 529 | return self.element("td", attrs, children); 530 | } 531 | pub fn template(self: Builder, attrs: anytype, children: anytype) Element { 532 | return self.element("template", attrs, children); 533 | } 534 | pub fn textarea(self: Builder, attrs: anytype, children: anytype) Element { 535 | return self.element("textarea", attrs, children); 536 | } 537 | pub fn tfoot(self: Builder, attrs: anytype, children: anytype) Element { 538 | return self.element("tfoot", attrs, children); 539 | } 540 | pub fn th(self: Builder, attrs: anytype, children: anytype) Element { 541 | return self.element("th", attrs, children); 542 | } 543 | pub fn thead(self: Builder, attrs: anytype, children: anytype) Element { 544 | return self.element("thead", attrs, children); 545 | } 546 | pub fn time(self: Builder, attrs: anytype, children: anytype) Element { 547 | return self.element("time", attrs, children); 548 | } 549 | pub fn title(self: Builder, attrs: anytype, children: anytype) Element { 550 | return self.element("title", attrs, children); 551 | } 552 | pub fn tr(self: Builder, attrs: anytype, children: anytype) Element { 553 | return self.element("tr", attrs, children); 554 | } 555 | pub fn tt(self: Builder, attrs: anytype, children: anytype) Element { 556 | return self.element("tt", attrs, children); 557 | } 558 | pub fn u(self: Builder, attrs: anytype, children: anytype) Element { 559 | return self.element("u", attrs, children); 560 | } 561 | pub fn ul(self: Builder, attrs: anytype, children: anytype) Element { 562 | return self.element("ul", attrs, children); 563 | } 564 | pub fn @"var"(self: Builder, attrs: anytype, children: anytype) Element { 565 | return self.element("var", attrs, children); 566 | } 567 | pub fn video(self: Builder, attrs: anytype, children: anytype) Element { 568 | return self.element("video", attrs, children); 569 | } 570 | pub fn xmp(self: Builder, attrs: anytype, children: anytype) Element { 571 | return self.element("xmp", attrs, children); 572 | } 573 | }; 574 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const fs = std.fs; 3 | const mem = std.mem; 4 | const print = std.debug.print; 5 | const fmt = std.fmt; 6 | const allocPrint = std.fmt.allocPrint; 7 | const dvui = @import("dvui"); 8 | const zqlite = @import("zqlite"); 9 | 10 | const sql = @import("sql.zig"); 11 | const history = @import("history.zig"); 12 | const theme = @import("theme.zig"); 13 | const djot = @import("djot.zig"); 14 | const queries = @import("queries.zig"); 15 | const server = @import("server.zig"); 16 | const constants = @import("constants.zig"); 17 | const PORT = constants.PORT; 18 | const EXTENSION = constants.EXTENSION; 19 | const core_ = @import("core.zig"); 20 | const GuiState = core_.GuiState; 21 | const Modal = core_.Modal; 22 | const Core = core_.Core; 23 | const sitefs = @import("sitefs.zig"); 24 | const blobstore = @import("blobstore.zig"); 25 | const println = @import("util.zig").println; 26 | 27 | const Backend = dvui.backend; 28 | comptime { 29 | std.debug.assert(@hasDecl(Backend, "SDLBackend")); 30 | } 31 | 32 | var maybe_server: ?server.Server = null; 33 | 34 | pub fn main() !void { 35 | var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){}; 36 | const gpa = gpa_impl.allocator(); 37 | defer _ = gpa_impl.deinit(); 38 | 39 | const argv = try std.process.argsAlloc(gpa); 40 | defer std.process.argsFree(gpa, argv); 41 | 42 | // wm2k 43 | // to start a web server. 44 | // This is to be run as a subprocess called by the main program. 45 | // It assumes the current working directory is the same dir that contains 46 | // the site.wm2k file. 47 | if (argv.len == 3 and mem.eql(u8, argv[1], server.SERVER_CMD)) { 48 | try server.serve(gpa, argv[2]); 49 | return; 50 | } 51 | 52 | // init SDL backend (creates and owns OS window) 53 | var backend = try Backend.initWindow(.{ 54 | .allocator = gpa, 55 | .size = .{ .w = 800.0, .h = 600.0 }, 56 | .min_size = .{ .w = 500, .h = 500 }, 57 | .vsync = true, 58 | .title = "WebMaker2000", 59 | .icon = @embedFile("favicon.png"), 60 | }); 61 | defer backend.deinit(); 62 | 63 | // init dvui Window (maps onto a single OS window) 64 | var default_theme = theme.default(); 65 | var win = try dvui.Window.init( 66 | @src(), 67 | gpa, 68 | backend.backend(), 69 | .{ .theme = &default_theme }, 70 | ); 71 | defer win.deinit(); 72 | 73 | // Add Noto Sans font which supports Vietnamese 74 | try win.font_bytes.put( 75 | "Noto", 76 | dvui.FontBytesEntry{ 77 | .ttf_bytes = @embedFile("fonts/NotoSans-Regular.ttf"), 78 | .allocator = null, 79 | }, 80 | ); 81 | try win.font_bytes.put( 82 | "NotoBd", 83 | dvui.FontBytesEntry{ 84 | .ttf_bytes = @embedFile("fonts/NotoSans-Bold.ttf"), 85 | .allocator = null, 86 | }, 87 | ); 88 | 89 | // Extra keybinds 90 | try win.keybinds.putNoClobber("wm2k_undo", .{ .control = true, .shift = false, .key = .z }); 91 | try win.keybinds.putNoClobber("wm2k_redo", .{ .control = true, .shift = true, .key = .z }); 92 | 93 | var core = Core{}; 94 | defer core.deinit(); 95 | 96 | if (argv.len == 2 and mem.endsWith(u8, argv[1], "." ++ EXTENSION)) { 97 | // TODO: how to handle errors (e.g. file not found) here? We can't draw 98 | // anything at this stage. 99 | 100 | const existing_file_path = argv[1]; 101 | 102 | const conn = try sql.openWithSaneDefaults(existing_file_path, zqlite.OpenFlags.EXResCode); 103 | core.maybe_conn = conn; 104 | // TODO: read user_version pragma to check if the db was initialized 105 | // correctly. If not, abort with error message somehow. 106 | 107 | // Change working directory to the same dir as the .wm2k file 108 | if (fs.path.dirname(existing_file_path)) |dir_path| { 109 | try std.posix.chdir(dir_path); 110 | } 111 | 112 | maybe_server = try server.Server.init(gpa, PORT); 113 | 114 | try blobstore.ensureDir(); 115 | 116 | const absolute_path = try fs.cwd().realpathAlloc(gpa, "."); 117 | defer gpa.free(absolute_path); 118 | 119 | const dir_name = fs.path.basename(absolute_path); 120 | const window_title = try fmt.allocPrintZ(gpa, "{s} - WebMaker2000", .{dir_name}); 121 | defer gpa.free(window_title); 122 | try queries.setStatusText(gpa, conn, "Opened {s}", .{dir_name}); 123 | _ = Backend.c.SDL_SetWindowTitle(backend.window, window_title); 124 | } 125 | 126 | try djot.init(gpa); 127 | defer djot.deinit(); 128 | 129 | // Create arena that is reset every frame: 130 | var arena = std.heap.ArenaAllocator.init(gpa); 131 | defer arena.deinit(); 132 | 133 | // In each frame: 134 | // - make sure arena is reset 135 | // - read gui_state fresh from database 136 | // - (the rest is dvui boilerplate) 137 | main_loop: while (true) { 138 | defer _ = arena.reset(.{ .retain_with_limit = 1024 * 1024 * 100 }); 139 | 140 | core.state = try GuiState.read(core.maybe_conn, arena.allocator()); 141 | 142 | // beginWait coordinates with waitTime below to run frames only when needed 143 | const nstime = win.beginWait(backend.hasEvent()); 144 | 145 | // marks the beginning of a frame for dvui, can call dvui functions after this 146 | try win.begin(nstime); 147 | 148 | // send all SDL events to dvui for processing 149 | const quit = try backend.addAllEvents(&win); 150 | if (quit) break :main_loop; 151 | 152 | // if dvui widgets might not cover the whole window, then need to clear 153 | // the previous frame's render 154 | _ = Backend.c.SDL_SetRenderDrawColor(backend.renderer, 0, 0, 0, 255); 155 | _ = Backend.c.SDL_RenderClear(backend.renderer); 156 | 157 | try gui_frame(&core, &backend, arena.allocator(), gpa); 158 | 159 | // marks end of dvui frame, don't call dvui functions after this 160 | // - sends all dvui stuff to backend for rendering, must be called before renderPresent() 161 | const end_micros = try win.end(.{}); 162 | 163 | // cursor management 164 | backend.setCursor(win.cursorRequested()); 165 | backend.textInputRect(win.textInputRequested()); 166 | 167 | // render frame to OS 168 | backend.renderPresent(); 169 | 170 | // waitTime and beginWait combine to achieve variable framerates 171 | const wait_event_micros = win.waitTime(end_micros, null); 172 | backend.waitEventTimeout(wait_event_micros); 173 | } 174 | 175 | if (maybe_server) |_| { 176 | maybe_server.?.deinit(); 177 | } 178 | } 179 | 180 | fn gui_frame( 181 | core: *Core, 182 | backend: *dvui.backend, 183 | arena: mem.Allocator, 184 | gpa: mem.Allocator, // for data that needs to survive to next frame 185 | ) !void { 186 | var background = try dvui.overlay(@src(), .{ 187 | .expand = .both, 188 | .background = true, 189 | .color_fill = .{ .name = .fill_window }, 190 | }); 191 | defer background.deinit(); 192 | 193 | switch (core.state) { 194 | 195 | // Let user either create new or open existing wm2k file: 196 | .no_file_opened => { 197 | var vbox = try dvui.box(@src(), .vertical, .{ 198 | .gravity_x = 0.5, 199 | .gravity_y = 0.5, 200 | }); 201 | defer vbox.deinit(); 202 | 203 | try dvui.label(@src(), "Create new site or open an existing one?", .{}, .{}); 204 | 205 | { 206 | var hbox = try dvui.box(@src(), .horizontal, .{ 207 | .expand = .both, 208 | .gravity_x = 0.5, 209 | .gravity_y = 0.5, 210 | }); 211 | defer hbox.deinit(); 212 | 213 | if (try dvui.button(@src(), "New...", .{}, .{})) { 214 | if (try dvui.dialogNativeFolderSelect(arena, .{ 215 | .title = "Create new site - Choose an empty folder", 216 | })) |new_site_dir_path| new_site_block: { 217 | var site_dir = try fs.cwd().openDir(new_site_dir_path, .{ .iterate = true }); 218 | defer site_dir.close(); 219 | 220 | // TODO: find cleaner way to check if dir has children 221 | var dir_iterator = site_dir.iterate(); 222 | var has_children = false; 223 | while (try dir_iterator.next()) |_| { 224 | has_children = true; 225 | break; 226 | } 227 | 228 | // TODO: show error message 229 | if (has_children) { 230 | try dvui.dialog(@src(), .{}, .{ 231 | .title = "Chosen folder was not empty!", 232 | .message = "Please choose an empty folder for your new site.", 233 | }); 234 | break :new_site_block; 235 | } 236 | 237 | try site_dir.setAsCwd(); 238 | 239 | const conn = try sql.openWithSaneDefaults( 240 | try arena.dupeZ(u8, constants.SITE_FILE), 241 | zqlite.OpenFlags.EXResCode | zqlite.OpenFlags.Create, 242 | ); 243 | core.maybe_conn = conn; 244 | 245 | try sql.execNoArgs(conn, "pragma foreign_keys = on"); 246 | 247 | try sql.execNoArgs(conn, "begin exclusive"); 248 | try sql.execNoArgs(conn, @embedFile("db_schema.sql")); 249 | try history.createTriggers(history.Undo, conn, arena); 250 | try history.createTriggers(history.Redo, conn, arena); 251 | try sql.execNoArgs(conn, "commit"); 252 | 253 | const filename = fs.path.basename(new_site_dir_path); 254 | try queries.setStatusText(arena, conn, "Created {s}", .{filename}); 255 | _ = Backend.c.SDL_SetWindowTitle( 256 | backend.window, 257 | try fmt.allocPrintZ(arena, "{s} - WebMaker2000", .{filename}), 258 | ); 259 | 260 | maybe_server = try server.Server.init(gpa, PORT); 261 | 262 | try blobstore.ensureDir(); 263 | 264 | // Apparently interaction with the system file dialog 265 | // does not count as user interaction in dvui, so 266 | // there's a chance the UI won't be refreshed after a 267 | // file is chosen. Therefore, we need to manually 268 | // tell dvui to draw the next frame right after this 269 | // frame: 270 | dvui.refresh(null, @src(), null); 271 | } 272 | } 273 | 274 | if (try dvui.button(@src(), "Open...", .{}, .{})) { 275 | if (try dvui.dialogNativeFileOpen(arena, .{ 276 | .title = "Open site", 277 | .filters = &.{"*." ++ EXTENSION}, 278 | })) |existing_file_path| { 279 | const conn = try sql.openWithSaneDefaults(existing_file_path, zqlite.OpenFlags.EXResCode); 280 | core.maybe_conn = conn; 281 | // TODO: read user_version pragma to check if the db was initialized 282 | // correctly. If not, abort with error message somehow. 283 | 284 | // Change working directory to the same dir as the .wm2k file 285 | if (fs.path.dirname(existing_file_path)) |dir_path| { 286 | try std.posix.chdir(dir_path); 287 | } 288 | 289 | maybe_server = try server.Server.init(gpa, PORT); 290 | 291 | try blobstore.ensureDir(); 292 | 293 | const dir_name = fs.path.basename(try fs.cwd().realpathAlloc(arena, ".")); 294 | try queries.setStatusText(arena, conn, "Opened {s}", .{dir_name}); 295 | _ = Backend.c.SDL_SetWindowTitle( 296 | backend.window, 297 | try fmt.allocPrintZ(arena, "{s} - WebMaker2000", .{dir_name}), 298 | ); 299 | 300 | // Apparently interaction with the system file dialog 301 | // does not count as user interaction in dvui, so 302 | // there's a chance the UI won't be refreshed after a 303 | // file is chosen. Therefore, we need to manually 304 | // tell dvui to draw the next frame right after this 305 | // frame: 306 | dvui.refresh(null, @src(), null); 307 | } 308 | } 309 | } 310 | }, 311 | 312 | // User has actually opened a file => show main UI: 313 | .opened => |state| { 314 | const conn = core.maybe_conn.?; 315 | 316 | const undos = state.history.undos; 317 | const redos = state.history.redos; 318 | 319 | // Handle keyboard shortcuts 320 | const evts = dvui.events(); 321 | for (evts) |*e| { 322 | switch (e.evt) { 323 | .key => |key| { 324 | if (key.action == .down) { 325 | if (key.matchBind("wm2k_undo")) { 326 | try history.undo(conn, undos); 327 | } else if (key.matchBind("wm2k_redo")) { 328 | try history.redo(conn, redos); 329 | } 330 | } 331 | }, 332 | else => {}, 333 | } 334 | } 335 | 336 | // Actual GUI starts here 337 | 338 | var frame = try dvui.box(@src(), .vertical, .{ 339 | .expand = .both, 340 | .background = false, 341 | }); 342 | defer frame.deinit(); 343 | 344 | { 345 | var toolbar = try dvui.box( 346 | @src(), 347 | .horizontal, 348 | .{ .expand = .horizontal }, 349 | ); 350 | defer toolbar.deinit(); 351 | 352 | if (try theme.button(@src(), "Undo", .{}, .{}, undos.len == 0)) { 353 | try history.undo(conn, undos); 354 | } 355 | 356 | if (try theme.button(@src(), "Redo", .{}, .{}, redos.len == 0)) { 357 | try history.redo(conn, redos); 358 | } 359 | 360 | const generate_disabled = state.scene == .editing and state.scene.editing.post_errors.hasErrors(); 361 | if (try theme.button(@src(), "Generate", .{}, .{}, generate_disabled)) { 362 | var timer = try std.time.Timer.start(); 363 | 364 | var cwd = fs.cwd(); 365 | try cwd.deleteTree(constants.OUTPUT_DIR); 366 | 367 | var out_dir = try cwd.makeOpenPath(constants.OUTPUT_DIR, .{}); 368 | defer out_dir.close(); 369 | try sitefs.generate(arena, conn, "", out_dir); 370 | 371 | const miliseconds = timer.read() / 1_000_000; 372 | try queries.setStatusText( 373 | arena, 374 | conn, 375 | "Generated static site in {d}ms.", 376 | .{miliseconds}, 377 | ); 378 | } 379 | 380 | //var buf: [100]u8 = undefined; 381 | //const fps_str = fmt.bufPrint(&buf, "{d:0>3.0} fps", .{dvui.FPS()}) catch unreachable; 382 | //try dvui.label(@src(), "{s}", .{fps_str}, .{ .gravity_x = 1 }); 383 | //dvui.refresh(null, @src(), null); 384 | 385 | const url = switch (state.scene) { 386 | .listing => try allocPrint(arena, "http://localhost:{d}", .{PORT}), 387 | .editing => |s| if (s.post_errors.hasErrors()) 388 | "" 389 | else 390 | try allocPrint(arena, "http://localhost:{d}/{s}", .{ PORT, s.post.slug }), 391 | }; 392 | if (url.len > 0 and try dvui.labelClick(@src(), "{s}", .{url}, .{ 393 | .gravity_x = 1.0, 394 | .color_text = .{ .color = .{ .r = 0x00, .g = 0x00, .b = 0xff } }, 395 | })) { 396 | try dvui.openURL(url); 397 | } 398 | } 399 | 400 | switch (state.scene) { 401 | .listing => |scene| { 402 | try dvui.label(@src(), "Posts", .{}, .{ .font_style = .title_1 }); 403 | 404 | if (try theme.button(@src(), "New post", .{}, .{}, false)) { 405 | try core.handleAction(conn, arena, .create_post); 406 | } 407 | 408 | { 409 | var scroll = try dvui.scrollArea(@src(), .{}, .{ 410 | .expand = .both, 411 | .max_size_content = .{ 412 | // FIXME: how to avoid hardcoded max height? 413 | .h = dvui.windowRect().h - 170, 414 | }, 415 | //.padding = .{ .x = 5 }, 416 | .margin = .all(5), 417 | .corner_radius = .all(0), 418 | .border = .all(1), 419 | .color_fill = .{ .name = .fill_window }, 420 | }); 421 | defer scroll.deinit(); 422 | 423 | for (scene.posts, 0..) |post, i| { 424 | var hbox = try dvui.box(@src(), .horizontal, .{ .id_extra = i }); 425 | defer hbox.deinit(); 426 | 427 | if (try theme.button(@src(), "Edit", .{}, .{}, false)) { 428 | try core.handleAction(conn, arena, .{ .edit_post = post.id }); 429 | } 430 | 431 | try dvui.label( 432 | @src(), 433 | "{d}. {s}", 434 | .{ post.id, post.title }, 435 | .{ .id_extra = i, .gravity_y = 0.5 }, 436 | ); 437 | } 438 | } 439 | 440 | try dvui.label(@src(), "{s}", .{state.status_text}, .{ 441 | .gravity_x = 1, 442 | .gravity_y = 1, 443 | }); 444 | }, 445 | 446 | .editing => |scene| { 447 | const post_errors = scene.post_errors; 448 | 449 | var vbox = try dvui.box( 450 | @src(), 451 | .vertical, 452 | .{ .expand = .both }, 453 | ); 454 | defer vbox.deinit(); 455 | 456 | try dvui.label(@src(), "Editing post #{d}", .{scene.post.id}, .{ .font_style = .title_1 }); 457 | 458 | var title_buf: []u8 = scene.post.title; 459 | var slug_buf: []u8 = scene.post.slug; 460 | var content_buf: []u8 = scene.post.content; 461 | 462 | try dvui.label(@src(), "Title:", .{}, .{ 463 | .padding = .{ 464 | .x = 5, 465 | .y = 5, 466 | .w = 5, 467 | .h = 0, // bottom 468 | }, 469 | }); 470 | var title_entry = try theme.textEntry( 471 | @src(), 472 | .{ 473 | .text = .{ 474 | .buffer_dynamic = .{ 475 | .backing = &title_buf, 476 | .allocator = arena, 477 | }, 478 | }, 479 | }, 480 | .{ .expand = .horizontal }, 481 | post_errors.empty_title, 482 | ); 483 | if (title_entry.text_changed) { 484 | try core.handleAction(conn, arena, .{ 485 | .update_post_title = .{ 486 | .id = scene.post.id, 487 | .title = title_entry.getText(), 488 | }, 489 | }); 490 | } 491 | title_entry.deinit(); 492 | 493 | try theme.errLabel(@src(), "{s}", .{ 494 | if (post_errors.empty_title) 495 | "Title must not be empty." 496 | else 497 | "", 498 | }); 499 | 500 | try dvui.label(@src(), "Slug:", .{}, .{ 501 | .padding = .{ 502 | .x = 5, 503 | .y = 5, 504 | .w = 5, 505 | .h = 0, // bottom 506 | }, 507 | }); 508 | var slug_entry = try theme.textEntry( 509 | @src(), 510 | .{ 511 | .text = .{ 512 | .buffer_dynamic = .{ 513 | .backing = &slug_buf, 514 | .allocator = arena, 515 | }, 516 | }, 517 | }, 518 | .{ .expand = .horizontal }, 519 | post_errors.empty_slug or post_errors.duplicate_slug, 520 | ); 521 | if (slug_entry.text_changed) { 522 | try core.handleAction(conn, arena, .{ 523 | .update_post_slug = .{ 524 | .id = scene.post.id, 525 | .slug = slug_entry.getText(), 526 | }, 527 | }); 528 | } 529 | slug_entry.deinit(); 530 | 531 | try theme.errLabel(@src(), "{s}{s}", .{ 532 | if (post_errors.empty_slug) 533 | "Slug must not be empty. " 534 | else 535 | "", 536 | if (post_errors.duplicate_slug) 537 | "Slug must be unique. " 538 | else 539 | "", 540 | }); 541 | 542 | { 543 | var paned = try dvui.paned( 544 | @src(), 545 | .{ .direction = .horizontal, .collapsed_size = 0 }, 546 | .{ 547 | .expand = .both, 548 | .background = false, 549 | .min_size_content = .{ .h = 100 }, 550 | }, 551 | ); 552 | defer paned.deinit(); 553 | 554 | { 555 | var content_vbox = try dvui.box(@src(), .vertical, .{ .expand = .both }); 556 | defer content_vbox.deinit(); 557 | 558 | try dvui.label(@src(), "Content:", .{}, .{ 559 | .padding = .{ 560 | .x = 5, 561 | .y = 5, 562 | .w = 5, 563 | .h = 0, // bottom 564 | }, 565 | }); 566 | var content_entry = try theme.textEntry( 567 | @src(), 568 | .{ 569 | .multiline = true, 570 | .break_lines = true, 571 | .scroll_horizontal = false, 572 | .text = .{ 573 | .buffer_dynamic = .{ 574 | .backing = &content_buf, 575 | .allocator = arena, 576 | }, 577 | }, 578 | }, 579 | .{ 580 | .expand = .both, 581 | .min_size_content = .{ .h = 80 }, 582 | }, 583 | post_errors.empty_content, 584 | ); 585 | if (content_entry.text_changed) { 586 | try core.handleAction(conn, arena, .{ 587 | .update_post_content = .{ 588 | .id = scene.post.id, 589 | .content = content_entry.getText(), 590 | }, 591 | }); 592 | } 593 | content_entry.deinit(); 594 | 595 | try theme.errLabel(@src(), "{s}", .{ 596 | if (post_errors.empty_content) 597 | "Content must not be empty." 598 | else 599 | "", 600 | }); 601 | } 602 | 603 | { 604 | var attachments_vbox = try dvui.box(@src(), .vertical, .{ .expand = .both }); 605 | defer attachments_vbox.deinit(); 606 | 607 | try dvui.label(@src(), "Attachments:", .{}, .{}); 608 | 609 | { 610 | var buttons_box = try dvui.box(@src(), .horizontal, .{}); 611 | defer buttons_box.deinit(); 612 | 613 | if (try theme.button(@src(), "Add...", .{}, .{}, false)) { 614 | if (try dvui.dialogNativeFileOpenMultiple(arena, .{ 615 | .title = "Add attachments", 616 | })) |file_paths| { 617 | try core.handleAction(conn, arena, .{ .add_attachments = .{ 618 | .post_id = scene.post.id, 619 | .file_paths = file_paths, 620 | } }); 621 | } 622 | } 623 | 624 | var delete_disabled = true; 625 | for (scene.attachments) |attachment| { 626 | if (attachment.selected) { 627 | delete_disabled = false; 628 | break; 629 | } 630 | } 631 | if (try theme.button(@src(), "Delete selected", .{}, .{}, delete_disabled)) { 632 | try core.handleAction(conn, arena, .{ .delete_selected_attachments = scene.post.id }); 633 | } 634 | } 635 | { 636 | var scroll = try dvui.scrollArea(@src(), .{}, .{ 637 | .expand = .both, 638 | .max_size_content = .{ 639 | // FIXME: how to avoid hardcoded max height? 640 | .h = attachments_vbox.childRect.h - 100, 641 | }, 642 | //.padding = .{ .x = 5 }, 643 | .margin = .all(5), 644 | .corner_radius = .all(0), 645 | .border = .all(1), 646 | .color_fill = .{ .name = .fill_window }, 647 | }); 648 | defer scroll.deinit(); 649 | 650 | var selected_bools = try std.ArrayList(bool).initCapacity(arena, scene.attachments.len); 651 | for (scene.attachments, 0..) |attachment, i| { 652 | try selected_bools.append(attachment.selected); 653 | 654 | var atm_group = try dvui.box(@src(), .horizontal, .{ .id_extra = i }); 655 | defer atm_group.deinit(); 656 | 657 | if (try dvui.buttonIcon(@src(), "copy", dvui.entypo.copy, .{}, .{ .max_size_content = .all(13) })) { 658 | try dvui.clipboardTextSet(attachment.name); 659 | try queries.setStatusText(arena, conn, "Copied file name: {s}", .{attachment.name}); 660 | } 661 | 662 | if (try dvui.checkbox( 663 | @src(), 664 | &selected_bools.items[i], 665 | try allocPrint(arena, "{s} ({s})", .{ attachment.name, attachment.size }), 666 | .{ .id_extra = i }, 667 | )) { 668 | if (selected_bools.items[i]) { 669 | try core.handleAction(conn, arena, .{ .select_attachment = attachment.id }); 670 | } else { 671 | try core.handleAction(conn, arena, .{ .deselect_attachment = attachment.id }); 672 | } 673 | } 674 | } 675 | } 676 | } 677 | } 678 | 679 | { 680 | var hbox = try dvui.box(@src(), .horizontal, .{ 681 | .expand = .horizontal, 682 | .gravity_y = 1, 683 | .margin = .{ .y = 10 }, 684 | }); 685 | defer hbox.deinit(); 686 | 687 | const back_disabled = post_errors.hasErrors(); 688 | if (try theme.button(@src(), "Back", .{}, .{}, back_disabled)) { 689 | try core.handleAction(conn, arena, .list_posts); 690 | } 691 | 692 | if (try theme.button(@src(), "Delete", .{}, .{}, false)) { 693 | try core.handleAction(conn, arena, .{ .delete_post = scene.post.id }); 694 | } 695 | 696 | try dvui.label(@src(), "{s}", .{state.status_text}, .{ 697 | .gravity_x = 1, 698 | .gravity_y = 1, 699 | }); 700 | } 701 | 702 | // Post deletion confirmation modal: 703 | if (scene.show_confirm_delete) { 704 | var modal = try dvui.floatingWindow( 705 | @src(), 706 | .{ .modal = true }, 707 | .{ .max_size_content = .{ .w = 500 } }, 708 | ); 709 | defer modal.deinit(); 710 | 711 | try dvui.windowHeader("Confirm deletion", "", null); 712 | try dvui.label(@src(), "Are you sure you want to delete this post?", .{}, .{}); 713 | 714 | { 715 | _ = try dvui.spacer(@src(), .{}, .{ .expand = .vertical }); 716 | var hbox = try dvui.box(@src(), .horizontal, .{ .gravity_x = 1.0 }); 717 | defer hbox.deinit(); 718 | 719 | if (try theme.button(@src(), "Yes", .{}, .{}, false)) { 720 | try core.handleAction(conn, arena, .{ .delete_post_yes = scene.post.id }); 721 | } 722 | 723 | if (try theme.button(@src(), "No", .{}, .{}, false)) { 724 | try core.handleAction(conn, arena, .{ .delete_post_no = scene.post.id }); 725 | } 726 | } 727 | } 728 | }, 729 | } 730 | }, 731 | } 732 | } 733 | -------------------------------------------------------------------------------- /src/maths.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const t = std.testing; 3 | 4 | pub fn humanReadableSize(arena: std.mem.Allocator, bytes: i64) ![]const u8 { 5 | std.debug.assert(bytes >= 0); 6 | 7 | switch (bytes) { 8 | 0...1023 => { 9 | return try std.fmt.allocPrint(arena, "{d}B", .{bytes}); 10 | }, 11 | 1024...1024 * 1024 - 1 => { 12 | const bytes_float: f64 = @floatFromInt(bytes); 13 | const kibibytes = bytes_float / 1024.0; 14 | return try std.fmt.allocPrint( 15 | arena, 16 | "{d:.1}KiB", 17 | .{kibibytes}, 18 | ); 19 | }, 20 | 1024 * 1024...1024 * 1024 * 1024 - 1 => { 21 | const bytes_float: f64 = @floatFromInt(bytes); 22 | const mebibytes = bytes_float / (1024.0 * 1024.0); 23 | return try std.fmt.allocPrint( 24 | arena, 25 | "{d:.1}MiB", 26 | .{mebibytes}, 27 | ); 28 | }, 29 | else => { 30 | const bytes_float: f64 = @floatFromInt(bytes); 31 | const gibibytes = bytes_float / (1024.0 * 1024.0 * 1024.0); 32 | return try std.fmt.allocPrint( 33 | arena, 34 | "{d:.1}GiB", 35 | .{gibibytes}, 36 | ); 37 | }, 38 | } 39 | } 40 | 41 | test humanReadableSize { 42 | const test_alloc = std.testing.allocator; 43 | var arena_impl = std.heap.ArenaAllocator.init(test_alloc); 44 | const arena = arena_impl.allocator(); 45 | defer arena_impl.deinit(); 46 | 47 | const cases = [_]std.meta.Tuple(&.{ i64, []const u8 }){ 48 | .{ 0, "0B" }, 49 | .{ 1023, "1023B" }, 50 | .{ 1024, "1.0KiB" }, 51 | .{ 1025, "1.0KiB" }, 52 | .{ 1115, "1.1KiB" }, // rounds up 53 | .{ 1024 * 1024 - 1, "1024.0KiB" }, // ugh 54 | .{ 1024 * 1024, "1.0MiB" }, 55 | .{ 1024 * 1024 * 1024, "1.0GiB" }, 56 | .{ 1115000000, "1.0GiB" }, 57 | .{ 2011111111, "1.9GiB" }, 58 | }; 59 | 60 | for (cases) |case| { 61 | try t.expectEqualStrings( 62 | case[1], 63 | try humanReadableSize(arena, case[0]), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/queries.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zqlite = @import("zqlite"); 3 | const sql = @import("sql.zig"); 4 | 5 | pub fn setStatusText( 6 | gpa: std.mem.Allocator, 7 | conn: zqlite.Conn, 8 | comptime fmt: []const u8, 9 | args: anytype, 10 | ) !void { 11 | const text = try std.fmt.allocPrint(gpa, fmt, args); 12 | defer gpa.free(text); 13 | 14 | return sql.exec(conn, 15 | \\update gui_status_text 16 | \\set status_text=?, expires_at = datetime('now', '+5 seconds') 17 | , .{text}); 18 | } 19 | 20 | pub fn setStatusTextNoAlloc(conn: zqlite.Conn, text: []const u8) !void { 21 | return sql.exec(conn, 22 | \\update gui_status_text 23 | \\set status_text=?, expires_at = datetime('now', '+5 seconds') 24 | , .{text}); 25 | } 26 | 27 | pub fn clearStatusText(conn: zqlite.Conn) !void { 28 | return sql.execNoArgs(conn, "update gui_status_text set status_text=''"); 29 | } 30 | -------------------------------------------------------------------------------- /src/server.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const net = std.net; 3 | const http = std.http; 4 | const mem = std.mem; 5 | const zqlite = @import("zqlite"); 6 | const sql = @import("sql.zig"); 7 | const djot = @import("djot.zig"); 8 | const sitefs = @import("sitefs.zig"); 9 | const constants = @import("constants.zig"); 10 | const println = @import("util.zig").println; 11 | 12 | pub const SERVER_CMD = "server"; 13 | 14 | pub const Server = struct { 15 | process: std.process.Child, 16 | 17 | /// Spawns a child process that starts the http preview server. 18 | /// Assumes cwd() is already the site's dir, in other words, the same dir 19 | /// the contains `site.wm2k`. 20 | pub fn init(gpa: mem.Allocator, port: u16) !Server { 21 | var exe_path_buf: [1024 * 5]u8 = undefined; 22 | const exe_path = try std.fs.selfExePath(&exe_path_buf); 23 | 24 | var port_buf: [5]u8 = undefined; 25 | const port_str = std.fmt.bufPrintIntToSlice(&port_buf, port, 10, .upper, .{}); 26 | 27 | const command: []const []const u8 = &.{ 28 | exe_path, 29 | SERVER_CMD, 30 | port_str, 31 | }; 32 | var proc = std.process.Child.init(command, gpa); 33 | try proc.spawn(); 34 | 35 | return .{ .process = proc }; 36 | } 37 | 38 | pub fn deinit(self: *Server) void { 39 | _ = self.process.kill() catch unreachable; 40 | } 41 | }; 42 | 43 | /// Main entry point of the preview server subprocess: 44 | pub fn serve(gpa: mem.Allocator, port_str: []const u8) !void { 45 | const port = try std.fmt.parseInt(u16, port_str, 10); 46 | 47 | try djot.init(gpa); 48 | defer djot.deinit(); 49 | 50 | println("Server starting at http://localhost:{d}", .{port}); 51 | 52 | const address = try net.Address.parseIp4("127.0.0.1", port); 53 | var net_server = try address.listen(.{ .reuse_address = true }); 54 | 55 | while (true) { 56 | println("Waiting for new connection...", .{}); 57 | const connection = net_server.accept() catch |err| { 58 | println("Connection to client interrupted: {}", .{err}); 59 | continue; 60 | }; 61 | var thread = try std.Thread.spawn(.{}, handle_request, .{connection}); 62 | thread.detach(); 63 | } 64 | } 65 | 66 | fn handle_request(connection: net.Server.Connection) !void { 67 | println("Incoming request", .{}); 68 | defer connection.stream.close(); 69 | 70 | var read_buffer: [1024 * 512]u8 = undefined; 71 | var http_server = http.Server.init(connection, &read_buffer); 72 | 73 | var request = http_server.receiveHead() catch |err| { 74 | println("Could not read head: {}", .{err}); 75 | return; 76 | }; 77 | 78 | println("Server serving {s}", .{request.head.target}); 79 | 80 | var conn = try zqlite.Conn.init( 81 | constants.SITE_FILE, 82 | zqlite.OpenFlags.EXResCode | zqlite.OpenFlags.ReadOnly, 83 | ); 84 | defer conn.close(); 85 | 86 | var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){}; 87 | defer _ = gpa_impl.deinit(); 88 | const gpa = gpa_impl.allocator(); 89 | var arena_imp = std.heap.ArenaAllocator.init(gpa); 90 | defer arena_imp.deinit(); 91 | const arena = arena_imp.allocator(); 92 | 93 | const response = try sitefs.serve(arena, conn, request.head.target); 94 | switch (response) { 95 | .success => |body| { 96 | try request.respond(body, .{}); 97 | }, 98 | .not_found => { 99 | try request.respond("404 Not Found", .{ .status = .not_found }); 100 | }, 101 | .redirect => |path| { 102 | try request.respond("", .{ 103 | .status = .moved_permanently, 104 | .extra_headers = &.{ 105 | .{ 106 | .name = "Location", 107 | .value = path, 108 | }, 109 | }, 110 | }); 111 | }, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/sitefs.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | const println = @import("util.zig").println; 4 | const allocPrint = std.fmt.allocPrint; 5 | const mem = std.mem; 6 | const fs = std.fs; 7 | const zqlite = @import("zqlite"); 8 | const sql = @import("sql.zig"); 9 | const djot = @import("djot.zig"); 10 | const html = @import("html.zig"); 11 | const blobstore = @import("blobstore.zig"); 12 | 13 | const MAX_URL_LEN = 2048; // https://stackoverflow.com/a/417184 14 | 15 | pub const Response = union(enum) { 16 | not_found: void, 17 | redirect: []const u8, 18 | success: []const u8, 19 | }; 20 | 21 | pub fn serve(arena: mem.Allocator, conn: zqlite.Conn, path: []const u8) !Response { 22 | assert(path.len >= 1); // even root must be "/", not "" 23 | 24 | if (path[path.len - 1] == '/') { 25 | return serve(arena, conn, try allocPrint(arena, "{s}index.html", .{path})); 26 | } 27 | 28 | const read_result = 29 | try read(.{ 30 | .arena = arena, 31 | .conn = conn, 32 | .path = path["/".len..], 33 | }); 34 | 35 | return switch (read_result) { 36 | .file => |content| .{ .success = content }, 37 | .dir => .{ .redirect = try allocPrint(arena, "{s}/", .{path}) }, 38 | .not_found => .not_found, 39 | }; 40 | } 41 | 42 | pub const ReadResult = union(enum) { 43 | file: []const u8, 44 | dir: []const []const u8, 45 | not_found: void, 46 | }; 47 | 48 | const ReadArgs = struct { 49 | arena: mem.Allocator, 50 | conn: zqlite.Conn, 51 | path: []const u8, 52 | list_children: bool = false, 53 | }; 54 | 55 | pub fn read(args: ReadArgs) !ReadResult { 56 | const path = args.path; 57 | const arena = args.arena; 58 | const conn = args.conn; 59 | const list_children = args.list_children; 60 | 61 | println("stat: {s}", .{path}); 62 | 63 | const prefix = if (path.len == 0) 64 | "" 65 | else 66 | try allocPrint(arena, "{s}/", .{path}); 67 | 68 | // Root dir: 69 | if (path.len == 0) { 70 | if (!list_children) return .{ .dir = &.{} }; 71 | 72 | var children = std.ArrayList([]const u8).init(arena); 73 | 74 | // index.html 75 | try children.append(try allocPrint(arena, "{s}index.html", .{prefix})); 76 | 77 | // a dir for each post 78 | // TODO: is there a better way to handle dupes? 79 | var rows = try sql.rows(conn, "select slug from post order by slug, id", .{}); 80 | defer rows.deinit(); 81 | while (rows.next()) |row| { 82 | const slug = try arena.dupe(u8, row.text(0)); 83 | try children.append( 84 | try allocPrint(arena, "{s}{s}", .{ prefix, slug }), 85 | ); 86 | } 87 | 88 | return .{ .dir = children.items }; 89 | } 90 | 91 | assert(path[0] != '/'); 92 | assert(path[path.len - 1] != '/'); 93 | 94 | // Home page 95 | if (mem.eql(u8, path, "index.html")) { 96 | var h = html.Builder{ .allocator = arena }; 97 | 98 | var posts = std.ArrayList(html.Element).init(arena); 99 | 100 | var rows = try sql.rows(conn, 101 | \\select slug, title 102 | \\from post 103 | \\where slug <> '' and title <> '' 104 | \\order by slug, id desc 105 | , .{}); 106 | defer rows.deinit(); 107 | while (rows.next()) |row| { 108 | const slug = try arena.dupe(u8, row.text(0)); 109 | const title = try arena.dupe(u8, row.text(1)); 110 | try posts.append( 111 | h.li(null, .{ 112 | h.a( 113 | .{ .href = try allocPrint(arena, "{s}/", .{slug}) }, 114 | .{title}, 115 | ), 116 | }), 117 | ); 118 | } 119 | 120 | var content = h.html( 121 | .{ .lang = "en" }, 122 | .{ 123 | h.head(null, .{ 124 | h.meta(.{ .charset = "utf-8" }), 125 | h.meta(.{ .name = "viewport", .content = "width=device-width, initial-scale=1.0" }), 126 | h.title(null, .{"Home | WebMaker2000 Preview"}), 127 | //h.link(.{ .rel = "stylesheet", .href = static.style_css.url_path }), 128 | //h.link(.{ .rel = "icon", .type = "image/png", .href = static.developers_png.url_path }), 129 | }), 130 | h.body(null, .{ 131 | h.h1(null, .{"Home"}), 132 | h.ul(null, .{posts.items}), 133 | }), 134 | }, 135 | ); 136 | 137 | return .{ .file = try content.toHtml(arena) }; 138 | } 139 | 140 | var parts = mem.splitScalar(u8, path, '/'); 141 | const post_slug = parts.next().?; 142 | 143 | const maybe_row = try sql.selectRow( 144 | conn, 145 | "select title, content from post where slug=?", 146 | .{post_slug}, 147 | ); 148 | var row = if (maybe_row) |r| r else return .not_found; 149 | defer row.deinit(); 150 | 151 | // Now that we're sure the url points to a post that exists, we can examine 152 | // the later parts if any: 153 | if (parts.next()) |second_part| { 154 | // Second part is either index.html or a post's attachment. 155 | // We'll handle index.html at the end. Here we only serve the 156 | // attachment. 157 | if (!mem.eql(u8, second_part, "index.html")) { 158 | const attachment_row = try sql.selectRow(conn, 159 | \\select a.hash 160 | \\from attachment a inner join post p on p.id = a.post_id 161 | \\where p.slug=? and a.name=? 162 | , .{ post_slug, second_part }); 163 | 164 | if (attachment_row) |r| { 165 | defer r.deinit(); 166 | const blob_hash = (r.text(0)[0 .. blobstore.HASH.digest_length * 2]).*; 167 | // TODO: don't read whole file into memory if we can help it 168 | return .{ .file = try blobstore.read(arena, blob_hash) }; 169 | } else { 170 | return .not_found; 171 | } 172 | } 173 | } else { 174 | // Post dir contains index.html and its attachments 175 | 176 | // index.html: 177 | const child = try allocPrint(arena, "{s}index.html", .{prefix}); 178 | var children = try std.ArrayList([]const u8).initCapacity(arena, 1); 179 | try children.append(child); 180 | 181 | // attachments: 182 | var attachment_rows = try sql.rows( 183 | conn, 184 | \\select a.name 185 | \\from attachment a 186 | \\ inner join post p on p.id = a.post_id 187 | \\where p.slug = ? 188 | , 189 | .{post_slug}, 190 | ); 191 | while (attachment_rows.next()) |arow| { 192 | try children.append( 193 | try allocPrint(arena, "{s}{s}", .{ prefix, arow.text(0) }), 194 | ); 195 | } 196 | try sql.check(attachment_rows.err, conn); 197 | 198 | return .{ .dir = children.items }; 199 | } 200 | 201 | // At this point we're sure our caller is requesting //index.html 202 | 203 | const title = row.text(0); 204 | const content = row.text(1); 205 | const content_html = try djot.toHtml(arena, content); 206 | 207 | const full_html = try std.fmt.allocPrint(arena, 208 | \\{s} 209 | \\

{s}

210 | \\{s} 211 | , .{ title, title, content_html }); 212 | 213 | return .{ .file = full_html }; 214 | } 215 | 216 | pub fn generate( 217 | arena: mem.Allocator, 218 | conn: zqlite.Conn, 219 | in_path: []const u8, 220 | out_dir: fs.Dir, 221 | ) !void { 222 | switch (try read(.{ 223 | .conn = conn, 224 | .path = in_path, 225 | .arena = arena, 226 | .list_children = true, 227 | })) { 228 | .file => |data| { 229 | try out_dir.writeFile(.{ 230 | .sub_path = in_path, 231 | .data = data, 232 | }); 233 | }, 234 | .dir => |children| { 235 | if (in_path.len > 0) try out_dir.makeDir(in_path); 236 | for (children) |child_path| { 237 | try generate(arena, conn, child_path, out_dir); 238 | } 239 | }, 240 | .not_found => unreachable, 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/sql.zig: -------------------------------------------------------------------------------- 1 | // Thin wrappers around zqlite, mostly to print the actual sql errors to make 2 | // debugging less painful. 3 | const std = @import("std"); 4 | const zqlite = @import("zqlite"); 5 | const blobstore = @import("blobstore.zig"); 6 | const println = @import("util.zig").println; 7 | 8 | pub fn openWithSaneDefaults(path: [:0]const u8, flags: c_int) !zqlite.Conn { 9 | const conn = try zqlite.open(path, flags); 10 | try execNoArgs(conn, 11 | \\PRAGMA foreign_keys = 1; 12 | \\PRAGMA busy_timeout = 3000; 13 | \\PRAGMA journal_mode = WAL; 14 | \\PRAGMA wal_autocheckpoint = 1000; 15 | // TODO: should we disable autocheckpoint? We already manually 16 | // checkpoint on exit, but then again, the WAL file seems to grow very 17 | // quickly without autocheckpoint... 18 | ); 19 | try blobstore.registerSqliteFunctions(conn); 20 | return conn; 21 | } 22 | 23 | pub fn exec(conn: zqlite.Conn, sql: []const u8, args: anytype) !void { 24 | conn.exec(sql, args) catch |err| { 25 | println(">> sql error: {s}", .{conn.lastError()}); 26 | return err; 27 | }; 28 | } 29 | 30 | pub fn execNoArgs(conn: zqlite.Conn, sql: [*:0]const u8) !void { 31 | conn.execNoArgs(sql) catch |err| { 32 | println(">> sql error: {s}", .{conn.lastError()}); 33 | return err; 34 | }; 35 | } 36 | 37 | pub fn rows(conn: zqlite.Conn, sql: []const u8, args: anytype) !zqlite.Rows { 38 | return conn.rows(sql, args) catch |err| { 39 | println(">> sql error: {s}", .{conn.lastError()}); 40 | return err; 41 | }; 42 | } 43 | 44 | pub fn check(err: ?zqlite.Error, conn: zqlite.Conn) !void { 45 | if (err != null) { 46 | println(">> sql error: {s}", .{conn.lastError()}); 47 | return err.?; 48 | } 49 | } 50 | 51 | pub fn selectRow(conn: zqlite.Conn, sql: []const u8, args: anytype) !?zqlite.Row { 52 | return (conn.row(sql, args) catch |err| { 53 | println(">> sql error: {s}", .{conn.lastError()}); 54 | return err; 55 | }); 56 | } 57 | 58 | /// Assumes the result is only 1 row with 1 column, which is an int. 59 | pub fn selectInt(conn: zqlite.Conn, sql: []const u8) !i64 { 60 | var row = (conn.row(sql, .{}) catch |err| { 61 | println(">> sql error: {s}", .{conn.lastError()}); 62 | return err; 63 | }).?; 64 | defer row.deinit(); 65 | return row.int(0); 66 | } 67 | -------------------------------------------------------------------------------- /src/theme.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const dvui = @import("dvui"); 3 | const Adwaita = @import("Adwaita.zig"); 4 | 5 | pub fn default() dvui.Theme { 6 | var theme = Adwaita.light; 7 | 8 | theme.font_body = .{ .size = 20, .name = "Noto" }; 9 | theme.font_heading = .{ .size = 20, .name = "NotoBd" }; 10 | theme.font_caption = .{ .size = 16, .name = "Noto", .line_height_factor = 1.1 }; 11 | theme.font_caption_heading = .{ .size = 16, .name = "NotoBd", .line_height_factor = 1.1 }; 12 | theme.font_title = .{ .size = 30, .name = "Noto" }; 13 | theme.font_title_1 = .{ .size = 28, .name = "NotoBd" }; 14 | theme.font_title_2 = .{ .size = 26, .name = "NotoBd" }; 15 | theme.font_title_3 = .{ .size = 24, .name = "NotoBd" }; 16 | theme.font_title_4 = .{ .size = 22, .name = "NotoBd" }; 17 | 18 | theme.color_fill_control = theme.color_fill_window; 19 | theme.color_fill_hover = dvui.Color.white; 20 | theme.color_border = dvui.Color.black; 21 | 22 | // Unfortunately some settings are configured not through the theme but via 23 | // some "defaults" variables instead. Setting them here isn't all that 24 | // clean, but at least it's nice to have all theme-related stuff in one 25 | // place. 26 | dvui.ButtonWidget.defaults.corner_radius = dvui.Rect.all(0); 27 | dvui.ButtonWidget.defaults.border = .{ .h = 3, .w = 3, .x = 1, .y = 1 }; 28 | dvui.ButtonWidget.defaults.padding = .{ .h = 2, .w = 6, .x = 6, .y = 2 }; 29 | dvui.TextEntryWidget.defaults.corner_radius = dvui.Rect.all(3); 30 | dvui.TextEntryWidget.defaults.color_border = .{ .color = .{ .r = 0x99, .g = 0x99, .b = 0x99 } }; 31 | dvui.FloatingWindowWidget.defaults.corner_radius = dvui.Rect.all(0); 32 | 33 | return theme; 34 | } 35 | 36 | /// Thin wrapper easily toggle text entry's invalid state. 37 | /// It adds the necessary styling. 38 | pub fn textEntry( 39 | src: std.builtin.SourceLocation, 40 | init_opts: dvui.TextEntryWidget.InitOptions, 41 | opts: dvui.Options, 42 | // TODO: maybe turn this bool into a list of errors for custom rendering? 43 | invalid: bool, 44 | ) !*dvui.TextEntryWidget { 45 | if (!invalid) return dvui.textEntry(src, init_opts, opts); 46 | 47 | var invalid_opts = opts; 48 | invalid_opts.color_fill = .{ .color = .{ .r = 0xff, .g = 0xeb, .b = 0xe9 } }; 49 | invalid_opts.color_accent = .{ .color = .{ .r = 0xff, .g = 0, .b = 0 } }; 50 | invalid_opts.color_border = .{ .color = .{ .r = 0xff, .g = 0, .b = 0 } }; 51 | return try dvui.textEntry(src, init_opts, invalid_opts); 52 | } 53 | 54 | /// Thin wrapper easily toggle button's disabled state. 55 | /// It adds the necessary styling, and always returns false when disabled. 56 | pub fn button( 57 | src: std.builtin.SourceLocation, 58 | label_str: []const u8, 59 | init_opts: dvui.ButtonWidget.InitOptions, 60 | opts: dvui.Options, 61 | disabled: bool, 62 | ) !bool { 63 | if (!disabled) return dvui.button(src, label_str, init_opts, opts); 64 | 65 | var disabled_opts = opts; 66 | disabled_opts.color_text = .{ .name = .fill_press }; 67 | disabled_opts.color_text_press = .{ .name = .fill_press }; 68 | disabled_opts.color_fill_hover = .{ .name = .fill_control }; 69 | disabled_opts.color_fill_press = .{ .name = .fill_control }; 70 | disabled_opts.color_accent = .{ .color = dvui.Color{ .a = 0x00 } }; 71 | _ = try dvui.button(src, label_str, init_opts, disabled_opts); 72 | return false; 73 | } 74 | 75 | pub fn errLabel(src: std.builtin.SourceLocation, comptime fmt: []const u8, args: anytype) !void { 76 | return dvui.label(src, fmt, args, .{ 77 | .color_text = .{ .name = .err }, 78 | .font_style = .caption, 79 | .padding = .{ 80 | .x = 5, 81 | .y = 0, // top 82 | .h = 5, 83 | .w = 5, 84 | }, 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /src/util.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | /// I keep forgetting the trailing \n, so this helper pays for itself 4 | pub fn println(comptime fmt: []const u8, args: anytype) void { 5 | std.debug.print(fmt ++ "\n", args); 6 | } 7 | -------------------------------------------------------------------------------- /xdg/.local/share/applications/webmaker2000.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Version=1.0 4 | Name=WebMaker2000 5 | Comment=Desktop web publishing application 6 | #Path=/opt/webmaker2000 7 | Exec=/usr/bin/wm2k 8 | Icon=webmaker2000 9 | MimeType=application/webmaker2000; 10 | Terminal=false 11 | Categories=Development; 12 | -------------------------------------------------------------------------------- /xdg/.local/share/icons/hicolor/scalable/apps/webmaker2000.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 40 | 50 | W 61 | 62 | 63 | -------------------------------------------------------------------------------- /xdg/.local/share/icons/hicolor/scalable/mimetypes/application-webmaker2000.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 14 | 17 | 23 | W 33 | 34 | 35 | -------------------------------------------------------------------------------- /xdg/.local/share/mime/packages/application-webmaker2000.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WebMaker2000 file 5 | 6 | 7 | 8 | 9 | 10 | --------------------------------------------------------------------------------