Compare commits

...

201 Commits

Author SHA1 Message Date
Jeremy Ruston 66b5ce8bed
Merge 268aaebaf0 into 67845f8ebe 2024-04-25 12:29:27 -04:00
FSpark 67845f8ebe
Fix: some plugin subtiddlers do not have title in savewikifolder command (#8151)
* fix: some plugin subtiddlers do not have title in savewikifolder command

* fix: following coding style
2024-04-25 18:29:09 +02:00
Jeremy Ruston 268aaebaf0 Logging 2024-04-16 14:51:26 +01:00
Jeremy Ruston ea318bab6e Fix file separator for Windows 2024-04-16 13:47:15 +01:00
Jeremy Ruston aafe775779 Debugging 2024-04-16 12:49:09 +01:00
Jeremy Ruston 129bbe421c Debugging 2024-04-16 12:45:35 +01:00
Jeremy Ruston 471ba99a5d Debugging 2024-04-16 12:43:41 +01:00
Jeremy Ruston 516a17a6f0 More debugging 2024-04-16 12:41:50 +01:00
Jeremy Ruston 2e4980bb97 Add some error checking 2024-04-16 12:38:05 +01:00
Jeremy Ruston db9978f8c2 Syncer should only save existing tiddlers if they have changed 2024-04-16 11:59:52 +01:00
Jeremy Ruston cc4cb04900 Turn on WAL mode for better-sqlite3 2024-04-14 19:12:21 +01:00
Jeremy Ruston 9ba4556250 Add support for the plugin library
We create a system bag to contain each plugin/theme/language. It seems wasteful because it results in lots of bags, but the semantics are exactly right and so it seems like the right approach
2024-04-14 18:41:34 +01:00
Jeremy Ruston 131a5abeb8 Protect against syncing client state tiddlers to the server
The default sync filter omits $:/state/, but users can customise it
2024-04-12 10:58:45 +01:00
Jeremy Ruston ce79a4add8 Add bag indicator to tiddler info panel 2024-04-12 10:28:26 +01:00
Jeremy Ruston 28a831489b Merge branch 'master' into multi-wiki-support 2024-04-12 09:59:12 +01:00
Jeremy Ruston 51cdca6841 Merge branch 'master' into multi-wiki-support 2024-04-03 21:51:51 +01:00
Jeremy Ruston d51ad80f80 Incoming updates filter should exclude tiddlers prefixed $:/StoryList and $:/HistoryList
Thanks @pmario
2024-03-28 17:32:10 +00:00
Jeremy Ruston ad528d6b1f Fix get wiki crash when serving a recipe with no tiddlers in it
Fixes #8110
2024-03-27 14:41:03 +00:00
Jeremy Ruston f2947e73b3 Filter updates from the server
We don't want $:/StoryList etc.
2024-03-26 17:16:14 +00:00
Jeremy Ruston baee0bb301 Fix broken updating of last_known_tiddler_id from streamed data 2024-03-26 12:25:49 +00:00
Jeremy Ruston 8a2111f150 Update GettingStarted 2024-03-26 12:25:13 +00:00
Jeremy Ruston fcffff3964 npm start should use mws-listen 2024-03-25 22:38:10 +00:00
Jeremy Ruston cca1f21d02 Undo unneeded changes to image widget 2024-03-25 22:35:42 +00:00
Jeremy Ruston 37f6930bf2 Quit command should abort any pending commands 2024-03-25 22:33:41 +00:00
Jeremy Ruston 4b1affee50 Load streamed tiddlers immediately, rather than scheduling a load 2024-03-25 17:49:34 +00:00
Jeremy Ruston 464d17b522 Update last known tiddler ID for events delivered via SSE 2024-03-25 17:43:29 +00:00
Jeremy Ruston d1bb7159b8 Expose the connection status in the UI 2024-03-25 17:07:36 +00:00
Jeremy Ruston b58cfe6324 SSE client: better state management to avoid multiple connections 2024-03-25 16:43:41 +00:00
Jeremy Ruston 7a0c43436f First pass at SSE support 2024-03-25 08:36:42 +00:00
Jeremy Ruston 708e21951f Fix syncing 2024-03-24 21:15:59 +00:00
Jeremy Ruston 8198574087 Remove code that is unneeded for the moment 2024-03-24 21:15:31 +00:00
Jeremy Ruston 6c9b92400e GET recipes/name/tiddlers.json should use include_deleted parameter 2024-03-24 18:32:56 +00:00
Jeremy Ruston 8091db37e8 Merge branch 'master' into multi-wiki-support 2024-03-23 17:32:03 +00:00
Jeremy Ruston e66b67dedc Only turn binary tiddlers into attachments 2024-03-23 09:54:15 +00:00
Jeremy Ruston a2012dcff8 Typo in comment 2024-03-23 09:54:03 +00:00
Jeremy Ruston 08649dd1eb More efficient syncing
Thank you @PotOfCoffee2Go I ended up taking some of your code from #8101 to get this up and running. There's still some stuff missing (like the tests!) but it gets things moving.
2024-03-23 09:27:54 +00:00
Jeremy Ruston 52f76380c7 Remove code relating to revision and bag as fields 2024-03-22 17:58:52 +00:00
Jeremy Ruston 1f63bcbbd0 Remove some templates that are no longer needed 2024-03-20 19:24:21 +00:00
Jeremy Ruston 3aa5607a3a Stop storing bag and revision details as tiddler fields
Instead we store them as dictionary tiddlers
2024-03-20 19:22:12 +00:00
Jeremy Ruston eaebeb87c9 Include bagname in tiddler Etags 2024-03-20 18:52:54 +00:00
Jeremy Ruston 808b94468e Fix multipart form data POST test 2024-03-20 18:52:37 +00:00
Jeremy Ruston 60e6c8bcb2 Fix etag handling 2024-03-20 17:56:47 +00:00
Jeremy Ruston 891f0fd599 Fix page body class in static pages
So that we get the TW background colour
2024-03-20 17:55:50 +00:00
Jeremy Ruston 6154de0d2c Rename misnamed route 2024-03-20 15:53:49 +00:00
Jeremy Ruston cae9dbf5d1 Fix colours in static renderings 2024-03-20 15:51:58 +00:00
Jeremy Ruston ae8ef305fa Get rid of those annoying missing image icons
By adding a `?fallback=url` parameter to tiddler requests
2024-03-20 15:40:38 +00:00
Jeremy Ruston 9b3ca525ee Introduce multiwikiclient plugin
Routes are now rationalised, too.
2024-03-20 15:13:50 +00:00
Jeremy Ruston 38ee942d8f Don't do full debug logging during tests 2024-03-20 15:12:35 +00:00
Jeremy Ruston 957329d515 Add store directories to gitignore 2024-03-20 13:10:30 +00:00
Jeremy Ruston 6063256439 Create new static index route with ability to create/update bags and recipes
Also introduces a new /.system/filename route for stylesheets, scripts etc.
2024-03-20 09:44:52 +00:00
Jeremy Ruston 1c64646393 Fix quit command to work gracefully 2024-03-19 10:04:32 +00:00
Jeremy Ruston 259b3dca1b Add a new static index route 2024-03-18 22:26:24 +00:00
Jeremy Ruston 6a673e6aea Stop syncing state tiddlers to the admin wiki 2024-03-18 21:08:48 +00:00
Jeremy Ruston f606e33415 Stop using the existing core server infrastructure
Too much of a constraint now that we're starting work on client-server sync
2024-03-18 08:44:45 +00:00
Jeremy Ruston 09de91940e Typo 2024-03-17 19:30:22 +00:00
Jeremy Ruston 3d485f0706 Expose tiddler_ids in bag and recipe listings 2024-03-17 16:34:45 +00:00
Jeremy Ruston 7eaa9b8aec Database methods that mutate tables should return IDs 2024-03-17 15:06:36 +00:00
Jeremy Ruston faa4b9700a More consistent variable naming 2024-03-17 14:54:06 +00:00
Jeremy Ruston dea739ff07 Introduce delete markers for deleted tiddlers 2024-03-17 14:18:47 +00:00
Jeremy Ruston 69cc45bf5c Refactor the database engine specific code 2024-03-17 13:27:00 +00:00
Jeremy Ruston 347aa4d546 Tests should cover tiddler_ids 2024-03-17 13:07:24 +00:00
Jeremy Ruston b4855b25c4 Merge branch 'master' into multi-wiki-support 2024-03-16 22:06:02 +00:00
Jeremy Ruston d518675e03 Get rid of some console.logs 2024-03-15 16:49:24 +00:00
Jeremy Ruston 9b59ae2b73 Add an HTTP test for multipart form data upload 2024-03-15 16:40:22 +00:00
Jeremy Ruston f67573315e POST /wiki/:bag_name/bags/:bag_name/tiddlers/ should also return JSON 2024-03-15 16:39:59 +00:00
Jeremy Ruston 501f57499e Update readme 2024-03-14 12:25:11 +00:00
Jeremy Ruston 2916cb6fd9 Clarify comment
Thanks @pmario
2024-03-14 11:55:55 +00:00
Jeremy Ruston e553539b2a Clarify that process.exit() is a hack 2024-03-13 22:12:41 +00:00
Jeremy Ruston 3da773c27f Add HTTP tests to npm test command 2024-03-13 18:39:42 +00:00
Jeremy Ruston b923be5e94 Merge branch 'master' into multi-wiki-support 2024-03-13 18:03:35 +00:00
Jeremy Ruston c9ab184c65 TODOs before merging 2024-03-12 21:32:22 +00:00
Jeremy Ruston bc45a16f40 Fix readme build 2024-03-12 21:21:49 +00:00
Jeremy Ruston 8b6642b56d Update the root readme that is visible on GitHub 2024-03-12 17:33:07 +00:00
Jeremy Ruston d6807cb471 Update MWS plugin readme 2024-03-12 17:32:54 +00:00
Jeremy Ruston f9064428c5 Add npm start command 2024-03-12 17:32:35 +00:00
Jeremy Ruston a443e5f0ad Add new command to test local or remote server via HTTP 2024-03-11 21:52:40 +00:00
Jeremy Ruston 24413c53dd The listen command shouldn't return until the server is listening 2024-03-11 21:45:27 +00:00
Jeremy Ruston 8b5c3746f8 Refactor command module filenames 2024-03-11 09:40:40 +00:00
Jeremy Ruston 9df625c44d Reorganise JS modules into subdirectories 2024-03-11 09:10:01 +00:00
Rob Hoelz 54ff0446c6
MWS: store level tests (#8021)
* Fix a few typos

The "database instead of store" change isn't a typo fix, per se, but
these tests are testing the lower-level database layer, and I'm about
to introduce some tests for the higher-level store layer, so I want to
avoid any confusion in the test names

* Start on SQL store-level tests

* Add some tests for createBag

* Add test for getBagTiddler and getBagTiddlers

* Add basic recipe tests

* Add a test for saving a tiddler within a recipe

* Add store test for deleteTiddler
2024-03-11 09:01:43 +00:00
Jeremy Ruston 0f5dfb89ad Refactor multipart form handling for more reusability 2024-03-10 20:20:06 +00:00
Jeremy Ruston e3b27768d2 Update comment 2024-03-10 20:19:41 +00:00
Jeremy Ruston 580283433e Remove unneeded highlight plugin from multiwikiserver edition 2024-03-10 18:07:02 +00:00
Jeremy Ruston f4ac2b92e7 Remove unneeded plugins from tw5.com edition 2024-03-10 18:06:44 +00:00
Jeremy Ruston e35584843d Fix mws-save-archive command name 2024-03-10 18:06:26 +00:00
Jeremy Ruston 3335e87ef4 Remove unneeded option 2024-03-10 17:49:52 +00:00
Jeremy Ruston abde67e5df MWS: Add support for large tiddlers to be stored as attachment files
Fixes #8022
2024-03-10 17:45:33 +00:00
Jeremy Ruston 2ba3643a0c Merge branch 'master' into multi-wiki-support 2024-03-07 08:26:10 +00:00
Jeremy Ruston 89ae2012c7 Merge branch 'master' into multi-wiki-support 2024-03-04 14:14:06 +00:00
Jeremy Ruston 8a209d643f Fix typo 2024-03-01 18:58:17 +00:00
Jeremy Ruston 1a28ec7ea4 Rename upload manager to multipart form manager 2024-02-28 18:36:19 +00:00
Jeremy Ruston 5fe41fc896 Reorder test tiddlers 2024-02-28 18:19:22 +00:00
Jeremy Ruston 4f9ff1ae81 Missed closing tag 2024-02-28 18:19:01 +00:00
Jeremy Ruston d97ddf1eec Merge branch 'master' into multi-wiki-support 2024-02-28 18:04:45 +00:00
Jeremy Ruston 9facf4a067 Fix incorrect encoding of description when creating bags and recipes 2024-02-28 17:46:19 +00:00
Rob Hoelz de4fe132a7
Unconditionally decrement transaction depth (#8008)
…otherwise we may end up in a situation where we're always stuck in an
"already in a transaction" state and often neglect to actually enter a
real transaction!
2024-02-26 11:02:55 +00:00
Jeremy Ruston d7d0733177 Remove accidentally committed database file
Thanks @hoelzro
2024-02-25 22:27:36 +00:00
Jeremy Ruston e614e291a2 Default to better-sqlite3 2024-02-25 18:08:59 +00:00
Jeremy Ruston dd9a3bfeeb Re-enable loading the tw5.com docs tiddlers 2024-02-25 18:04:54 +00:00
Jeremy Ruston 83229ace63 Add a config tiddler for selecting the SQLite engine 2024-02-25 18:04:43 +00:00
Jeremy Ruston 6724fa804b Run the tests on both node-sqlite3-wasm and better-sqlite3 2024-02-25 17:58:53 +00:00
Jeremy Ruston 630b98520f Add note about transaction handling 2024-02-25 09:48:24 +00:00
Jeremy Ruston 1c0341de51 Merge branch 'master' into multi-wiki-support 2024-02-24 09:38:42 +00:00
Jeremy Ruston d5aa74d9af Improve manual transaction handling
Thanks @hoelzro
2024-02-24 09:31:59 +00:00
Jeremy Ruston 343cc33bbe Add a test tiddler with emoji title
Useful for testing
2024-02-24 09:30:20 +00:00
Jeremy Ruston b1edbed6a5 Fix bag static HTML to show emoji correctly 2024-02-23 17:47:00 +00:00
Jeremy Ruston 066e553f84 Introduce command to load tiddler folders into a bag 2024-02-23 12:51:29 +00:00
Jeremy Ruston 61b54125be Rename mws-load|save to mws-load|save-archive 2024-02-23 12:51:07 +00:00
Jeremy Ruston 3276703edd Fix failing tests 2024-02-23 09:30:12 +00:00
Jeremy Ruston f9265169fd Commands to load and save tiddlers, bags and recipes as a JSON archive
@linonetwo the resulting archive should be suitable for storing in git
2024-02-23 09:27:53 +00:00
Jeremy Ruston 2361880c45 createBag should optionally set access control data 2024-02-23 09:26:45 +00:00
Jeremy Ruston 3ad87df154 Allow wiki engine to be configured 2024-02-22 17:58:30 +00:00
Jeremy Ruston 3c58788e37 Merge branch 'master' into multi-wiki-support 2024-02-22 17:39:51 +00:00
Jeremy Ruston 310b5f058a Whitespace 2024-02-22 12:13:53 +00:00
Jeremy Ruston a33705e348 Fix error
It appears that not all statements can be finalized.
2024-02-22 12:09:36 +00:00
Jeremy Ruston 3fca82321e
MWS: Add support for node-sqlite-wasm alongside better-sqlite3 (#7996)
* Switch from better-sqlite3 to node-sqlite3-wasm

Seems to be slower, but might make cloud deployments easier by not having any binary dependencies

* More logging

* Temporarily use a memory database

We will make this configurable

* Revert "More logging"

* Resume loading demo tiddlers

* Cache prepared statements

Gives a 20% reduction in startup time on my machine

* Some more logging

* Update package-lock

* More logging

* Route regexps should allow for proxies that automatically decode URLs

Astonishingly, Azure does this

* Go back to a file-based database

* Less logging

* Update package-lock.json

* Simplify startup by not loading the docs edition

* Tiddler database layer should mark statements as having been removed

* Re-introduce better-sqlite3

* Make the SQLite provider be switchable

* Support switchable SQL engines

I am not intending to make this a long term feature. We will choose one engine and stick with it until we choose to change to another.

* Adjust dependency versions

* Setting up default engine

* Make transaction handling compatible with node-sqlite3-wasm

https://github.com/tndrle/node-sqlite3-wasm doesn't have transaction support so I've tried to implement it using SQL statements directly.

@hoelzro do you think this is right? Should we be rolling back the transaction in the finally clause? It would be nice to have tests in this area...

I looked at better-sqlite3's implementation - https://github.com/WiseLibs/better-sqlite3/blob/master/lib/methods/transaction.js

* Default to better-sqlite3 for compatibility after merging
2024-02-22 11:57:41 +00:00
Rob Hoelz 790f431df0
MWS: Use transactions when modifying multiple resources (#7991)
* Use transactions when modifying multiple resources

This prevents partial changes from entering the database, and also
nets a nice speed-up.

* Keep track of transaction depth

…so we could someday potentially leverage SQL implementations that don't
implement nested transactions
2024-02-22 10:48:39 +00:00
Jeremy Ruston 0d22bf8418 Update to latest better-sqlite3 2024-02-22 10:47:08 +00:00
Jeremy Ruston 1eecfb6b3a Less logging 2024-02-21 17:55:13 +00:00
Jeremy Ruston b8c1c6c8de Allow backslashes in trailing API path components
To make us more tolerant of proxies that "helpfully" decodeuricomponent for us (looking at you Azure)
2024-02-21 17:54:56 +00:00
Jeremy Ruston 6503fb4a04 Simple performance logging 2024-02-20 09:33:39 +00:00
Jeremy Ruston bab14b7053 Logging 2024-02-19 16:29:14 +00:00
Jeremy Ruston 2d4b3341f6 Merge branch 'master' into multi-wiki-support 2024-02-19 09:55:57 +00:00
Jeremy Ruston 6f8a3b9261 mws-load command: more validation tiddler files 2024-02-16 16:41:39 +00:00
Jeremy Ruston 8edefffbc5 WIP: Support for streaming multipart form data to the file system 2024-02-16 16:02:40 +00:00
Jeremy Ruston 59b425fd5c Update to better-sqlite3 v9.4.1 2024-02-16 16:00:46 +00:00
Jeremy Ruston f2267e2af0 Merge branch 'master' into multi-wiki-support 2024-02-16 15:58:08 +00:00
Jeremy Ruston c26acfdb42 Add NOT NULL constraint to all columns
Thanks @hoelzro
2024-02-05 16:15:35 +00:00
Jeremy Ruston f925f036c9 Introduce $tw.mws for MWS globals 2024-02-05 14:49:08 +00:00
Jeremy Ruston 2c810faeeb Add barebones support for timing HTTP response generation times 2024-02-02 15:42:47 +00:00
Jeremy Ruston 6675358e85 WIP: Add a multipart/form-data convenience function
This is the start of adding support for large attachments.

We have a new endpoint for POSTing tiddler data. The idea is that it will take any kind of data and figure out how to extract tiddlers from the upload and save them in the nominated bag.

The next step is to move the attachment files into a special folder and reference them from the database so that we can construct _canonical_uris for them.
2024-02-02 15:42:02 +00:00
Jeremy Ruston 262a730534 Move the database file into a "store" directory inside the wiki folder 2024-01-29 18:11:50 +00:00
Jeremy Ruston 4b6872aa42 Fix typo 2024-01-29 08:29:26 +00:00
Jeremy Ruston 3283d38867 First draft of a command to read tiddlers, bags and recipes from an archive
The archive format is a legacy format that I used with Xememex, and will need to be updated to iron out the wrinkles
2024-01-28 21:27:12 +00:00
Jeremy Ruston 4204ff367e A few more tests 2024-01-28 17:11:53 +00:00
Jeremy Ruston 51e646690c Allow tilde character in bag and recipe names 2024-01-28 17:11:44 +00:00
Jeremy Ruston 85607f7846 Query fixes 2024-01-28 17:11:23 +00:00
Jeremy Ruston 41a5bcc3a1 Fix canonical URI handling 2024-01-26 15:48:39 +00:00
Jeremy Ruston 84c8a9be9b Fix typo 2024-01-26 15:01:07 +00:00
Jeremy Ruston 62b2fe3e2f Add an error when creating a recipe with no bags 2024-01-26 14:42:43 +00:00
Jeremy Ruston f5fdd79c7f Refresh when creating bags and recipes to get the change instantly 2024-01-26 14:42:17 +00:00
Jeremy Ruston 14752ccb0c Missing comma 2024-01-26 14:04:54 +00:00
Jeremy Ruston 541c166863 Error handling for bag and recipe handling 2024-01-26 14:03:32 +00:00
Jeremy Ruston 270f62bbb2 Merge branch 'master' into multi-wiki-support 2024-01-26 13:38:02 +00:00
Jeremy Ruston 8290d853c9 Merge branch 'master' into multi-wiki-support 2024-01-26 12:54:40 +00:00
Jeremy Ruston b0a67300cc Add support for bag descriptions, validate bags and recipes, and complete the UI for creating bags and recipes
Styling to come
2024-01-24 22:24:24 +00:00
Jeremy Ruston 0b9749f3a4 Add favicon to bag listing page 2024-01-24 20:58:57 +00:00
Jeremy Ruston 3ad3e19392 Merge branch 'master' into multi-wiki-support 2024-01-24 16:21:58 +00:00
Jeremy Ruston ed71adac7e Merge branch 'master' into multi-wiki-support 2024-01-24 12:54:39 +00:00
Jeremy Ruston 8d95c92bfb Use $:/state/ tiddler for server entity state tiddlers 2024-01-24 10:56:23 +00:00
Jeremy Ruston 41ab94978f Renaming tiddlers for consistency 2024-01-24 10:55:14 +00:00
Jeremy Ruston 26e198b7d8 First pass at MWS icon 2024-01-23 22:05:58 +00:00
Jeremy Ruston d16746ab38 Extend image widget to support alternate content if a remote image fails to load
...and use it to add an image for bags/recipes that do not have a $:/favicon.ico
2024-01-23 22:05:22 +00:00
Jeremy Ruston 627c3e20cc Add docs bags 2024-01-23 16:53:12 +00:00
Jeremy Ruston 4d42d4a190 Escape less than sign
Otherwise tiddlers containing `</script>` will break TiddlyWiki
2024-01-23 16:52:49 +00:00
Jeremy Ruston ff184822ca Don't wikify recipe descriptions 2024-01-23 16:52:11 +00:00
Jeremy Ruston ddbd6d1e82 Fix favicon aspect ratio 2024-01-23 16:51:57 +00:00
Jeremy Ruston f6d6478944 Add support for recipe descriptions 2024-01-23 14:29:50 +00:00
Jeremy Ruston 138c7f2665 Don't use the filesystem plugin
Otherwise changes to _multiwikiserver/ tiddlers are saved back to the file system

Perhaps it would work better to configure the wiki to sync state tiddlers to the browser.
2024-01-23 14:29:08 +00:00
Jeremy Ruston 239ace0c07 Avoid clients of sqlTiddlerStore having to call updateAdminWIki() explicitly 2024-01-23 12:53:06 +00:00
Jeremy Ruston c1312100aa Admin UI styling 2024-01-23 12:52:40 +00:00
Jeremy Ruston e343eccdc3 Refactor _canonical_uri handling out of route handlers 2024-01-23 10:51:12 +00:00
Jeremy Ruston da5b316358 Split SqlTiddlerStore into SqlTiddlerStore and SqlTiddlerDatabase
The motivation is to encapsulate knowledge of the SQL queries
2024-01-22 22:08:55 +00:00
Jeremy Ruston dc8692044c Use SQLite's AUTOINCREMENT to give us tiddler version identifiers
This commit fixes sync within hosted wikis
2024-01-21 18:18:29 +00:00
Jeremy Ruston 4f9ba11489 Update to newest better-sqlite3 2024-01-21 18:17:23 +00:00
Jeremy Ruston f7914db019 Add bag and recipe favicons to dashboard 2024-01-20 21:50:12 +00:00
Jeremy Ruston 11ecaff7db Fix typo 2024-01-20 21:48:40 +00:00
Jeremy Ruston d832bbcc70 Order the results of getRecipeTiddlers 2024-01-20 21:48:33 +00:00
Jeremy Ruston 59aed49e98 Make getRecipeTiddlers return the bagname as well 2024-01-20 20:22:46 +00:00
Jeremy Ruston e9f83ca735 Add missing LIMIT 1 2024-01-19 22:03:07 +00:00
Jeremy Ruston afa9ad3cde Update store.getRecipeTiddler to also return the bag from which the tiddler came 2024-01-19 20:35:47 +00:00
Jeremy Ruston 01d29ed11e get bag tiddler and put recipe tiddler should return the bag name 2024-01-19 20:12:29 +00:00
Jeremy Ruston 8f9ae7e4d5 Clarify method name 2024-01-19 19:52:57 +00:00
Jeremy Ruston 70b048f356 Fix bag links 2024-01-19 19:36:36 +00:00
Jeremy Ruston 5fddd3b104 Add support for retrieving tiddlers from bags 2024-01-19 19:33:58 +00:00
Jeremy Ruston 02afbb4000 Rename some of the routes more logically 2024-01-19 19:27:54 +00:00
Jeremy Ruston 54432485e7 Add an HTML view of bag listings 2024-01-19 19:25:58 +00:00
Jeremy Ruston 26ede2839b Add support for _canonical_uri tiddlers
Currently hard wired to kick in for tiddlers over 10MB (in base64 representation for binary tiddlers)
2024-01-19 14:46:21 +00:00
Jeremy Ruston 4b0df1a7ae Basic support for creating bags and recipes
Cannot yet specify the bags for the new recipe
2024-01-19 11:03:58 +00:00
Jeremy Ruston 9767e7d3b7 Update entity state tiddlers on startup to read bag and recipe info 2024-01-19 11:03:27 +00:00
Jeremy Ruston 4133e7d6d6 Stream wiki generation
Avoids "string too long" errors when working with big tiddlers (>100MB)
2024-01-19 10:52:12 +00:00
Jeremy Ruston 4f37355a9f Tests should use a dummy admin wiki 2024-01-19 10:28:04 +00:00
Jeremy Ruston 82fae45656 Admin styling 2024-01-18 21:48:09 +00:00
Jeremy Ruston 2f09c32d2d Fix getTiddler query 2024-01-18 21:47:57 +00:00
Jeremy Ruston a16338ce11 Merge branch 'master' into multi-wiki-support 2024-01-18 16:43:43 +00:00
Jeremy Ruston 50d0b1412d Fix CI tests 2024-01-18 09:02:41 +00:00
Jeremy Ruston 8941bd1747 Server extension framework
May not actually be needed
2024-01-17 22:42:01 +00:00
Jeremy Ruston 615dc0c4a3 First pass at admin user interface 2024-01-17 22:41:41 +00:00
Jeremy Ruston 1fb8b2e279 Fix broken test 2024-01-05 15:45:40 +00:00
Jeremy Ruston 0799177cf4 Add another recipe, improve docs 2024-01-05 15:40:39 +00:00
Jeremy Ruston 1eed61397b Fix create recipe SQL bug 2024-01-05 15:37:48 +00:00
Jeremy Ruston 3f1f7c7ef7 Remove debugging code 2024-01-05 11:08:33 +00:00
Jeremy Ruston 8543dda4aa Fix broken test 2024-01-05 11:01:10 +00:00
Jeremy Ruston 68a89b615d Use a persistent disk-based database 2024-01-05 10:58:07 +00:00
Jeremy Ruston e9d3f67c5c Add new multiwikiserver edition 2024-01-03 16:47:20 +00:00
Jeremy Ruston a980390870 Implement APIs for client wikis to sync with the server
It is now possible to create and edit tiddlers, using the existing tiddlywebadaptor syncing mechanism. There are a lot of hacks and lumpiness to make things compatible, so I think I will end up with an independent implementation
2024-01-03 16:27:13 +00:00
Jeremy Ruston 299781bdba Update docs 2024-01-02 21:47:08 +00:00
Jeremy Ruston f42d3e0536 Update usage instructions 2024-01-02 21:41:25 +00:00
Jeremy Ruston 993eb5c90d Tests need npm install 2024-01-02 21:41:06 +00:00
Jeremy Ruston f8f8319324 Add dependencies to package.json
This is needed in order for our CI to be able to run the tests
2024-01-02 14:47:32 +00:00
Jeremy Ruston 12d84c43c9 Initial Commit 2024-01-02 14:39:14 +00:00
87 changed files with 6365 additions and 17 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
tmp/
output/
node_modules/
store/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -0,0 +1,4 @@
The `multi-wiki-support` branch includes some changes that are not intended to be merged into the `master` branch:
* Readme update (see `editions/tw5.com/tiddlers/readme/ReadMe.tid`)
* Remove `multiwikiserver` plugin from `readme-bld.sh` (see `bin/readme-bld.sh`)

View File

@ -2,6 +2,8 @@
# test TiddlyWiki5 for tiddlywiki.com
npm install
node ./tiddlywiki.js \
./editions/test \
--verbose \

View File

@ -10,6 +10,7 @@ fi
# tw5.com readmes
node $TW5_BUILD_TIDDLYWIKI \
+plugins/tiddlywiki/multiwikiserver \
editions/tw5.com \
--verbose \
--output . \

View File

@ -38,6 +38,13 @@ Commander.prototype.log = function(str) {
}
};
/*
Clear pending commands
*/
Commander.prototype.clearCommands = function() {
this.commandTokens = this.commandTokens.slice(0,this.nextToken);
};
/*
Write a string if verbose flag is set
*/

View File

@ -16,7 +16,7 @@ var Server = require("$:/core/modules/server/server.js").Server;
exports.info = {
name: "listen",
synchronous: true,
synchronous: false,
namedParameterMode: true,
mandatoryParameters: []
};
@ -38,7 +38,11 @@ Command.prototype.execute = function() {
wiki: this.commander.wiki,
variables: self.params
});
var nodeServer = this.server.listen();
var nodeServer = this.server.listen(null,null,null,{
callback: function() {
self.callback();
}
});
$tw.hooks.invokeHook("th-server-command-post-start",this.server,nodeServer,"tiddlywiki");
return null;
};

View File

@ -0,0 +1,37 @@
/*\
title: $:/core/modules/commands/quit.js
type: application/javascript
module-type: command
Immediately ends the TiddlyWiki process
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "quit",
synchronous: true
};
var Command = function(params,commander,callback) {
var self = this;
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
// Clear any pending commands
this.commander.clearCommands();
// We don't actually quit, we just issue the "th-quit" hook to give listeners a chance to exit
$tw.hooks.invokeHook("th-quit");
return null;
};
exports.Command = Command;
})();

View File

@ -176,7 +176,10 @@ WikiFolderMaker.prototype.saveCustomPlugin = function(pluginTiddler) {
this.saveJSONFile(directory + path.sep + "plugin.info",pluginInfo);
self.log("Writing " + directory + path.sep + "plugin.info: " + JSON.stringify(pluginInfo,null,$tw.config.preferences.jsonSpaces));
var pluginTiddlers = $tw.utils.parseJSONSafe(pluginTiddler.fields.text).tiddlers; // A hashmap of tiddlers in the plugin
$tw.utils.each(pluginTiddlers,function(tiddler) {
$tw.utils.each(pluginTiddlers,function(tiddler,title) {
if(!tiddler.title) {
tiddler.title = title;
}
self.saveTiddler(directory,new $tw.Tiddler(tiddler));
});
};

View File

@ -364,6 +364,11 @@ Server.prototype.listen = function(port,host,prefix) {
}
// Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port)
server.on("listening",function() {
// Stop listening when we get the "th-quit" hook
$tw.hooks.addHook("th-quit",function() {
server.close();
});
// Log listening details
var address = server.address(),
url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix;
$tw.utils.log("Serving on " + url,"brown/orange");

View File

@ -257,7 +257,11 @@ Save an incoming tiddler in the store, and updates the associated tiddlerInfo
Syncer.prototype.storeTiddler = function(tiddlerFields) {
// Save the tiddler
var tiddler = new $tw.Tiddler(tiddlerFields);
this.wiki.addTiddler(tiddler);
// Only save the tiddler if it has changed
var existingTiddler = this.wiki.getTiddler(tiddlerFields.title);
if(!existingTiddler || !existingTiddler.isEqual(tiddler)) {
this.wiki.addTiddler(tiddler);
}
// Save the tiddler revision and changeCount details
this.tiddlerInfo[tiddlerFields.title] = {
revision: this.getTiddlerRevision(tiddlerFields.title),

View File

@ -163,4 +163,4 @@ ImageWidget.prototype.refresh = function(changedTiddlers) {
exports.image = ImageWidget;
})();
})();

View File

@ -0,0 +1,2 @@
title: $:/config/MultiWikiServer/Engine
text: better

View File

@ -0,0 +1,22 @@
{
"description": "Multiple wiki client-server edition",
"plugins": [
"tiddlywiki/tiddlyweb",
"tiddlywiki/filesystem",
"tiddlywiki/multiwikiclient",
"tiddlywiki/multiwikiserver"
],
"themes": [
"tiddlywiki/vanilla",
"tiddlywiki/snowwhite"
],
"build": {
"index": [
"--render","$:/plugins/tiddlywiki/tiddlyweb/save/offline","index.html","text/plain"],
"static": [
"--render","$:/core/templates/static.template.html","static.html","text/plain",
"--render","$:/core/templates/alltiddlers.template.html","alltiddlers.html","text/plain",
"--render","[!is[system]]","[encodeuricomponent[]addprefix[static/]addsuffix[.html]]","text/plain","$:/core/templates/static.tiddler.html",
"--render","$:/core/templates/static.template.css","static/static.css","text/plain"]
}
}

View File

@ -1,7 +1,8 @@
{
"description": "TiddlyWiki core tests",
"plugins": [
"tiddlywiki/jasmine"
"tiddlywiki/jasmine",
"tiddlywiki/multiwikiserver"
],
"themes": [
"tiddlywiki/vanilla",

View File

@ -6,6 +6,24 @@ type: text/vnd.tiddlywiki
\define tv-wikilink-template() https://tiddlywiki.com/static/$uri_doubleencoded$.html
\import [subfilter{$:/core/config/GlobalImportFilter}]
---
! ~TiddlyWiki ~MultiWikiServer
UNDER DEVELOPMENT
This is a branch of TiddlyWiki that adds the ~MultiWikiServer plugin.
!! Readme
{{$:/plugins/tiddlywiki/multiwikiserver/readme}}
!! Docs
{{$:/plugins/tiddlywiki/multiwikiserver/docs}}
---
Welcome to TiddlyWiki, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.
TiddlyWiki is a complete interactive wiki in JavaScript. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable WikiText.

1648
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -26,15 +26,18 @@
"devDependencies": {
"eslint": "^7.32.0"
},
"bundleDependencies": [],
"license": "BSD",
"engines": {
"node": ">=0.8.2"
},
"scripts": {
"dev": "node ./tiddlywiki.js ./editions/tw5.com-server --listen",
"test": "node ./tiddlywiki.js ./editions/test --verbose --version --build index",
"start": "node ./tiddlywiki.js ./editions/multiwikiserver --mws-listen",
"test": "node ./tiddlywiki.js ./editions/test --verbose --version --build index && node ./tiddlywiki ./editions/multiwikiserver/ --mws-listen --mws-test-server http://127.0.0.1:8080/ --quit",
"lint:fix": "eslint . --fix",
"lint": "eslint ."
},
"dependencies": {
"better-sqlite3": "^9.4.3",
"node-sqlite3-wasm": "^0.8.10"
}
}

View File

@ -0,0 +1,9 @@
title: GettingStarted
tags: $:/tags/GettingStarted
caption: Step 1<br>Syncing
! ~TiddlyWiki ~MultiWikiServer
Welcome to ~TiddlyWiki and the ~TiddlyWiki community.
Please note that ~MultiWikiServer is under active development, and may not be fully robust. Do not use it for anything critical.

View File

@ -0,0 +1,3 @@
title: $:/config/SaveWikiButton/Template
$:/plugins/tiddlywiki/multiwikiclient/save/offline

View File

@ -0,0 +1,2 @@
title: $:/config/multiwikiclient/incoming-updates-filter
text: [all[]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]] -[prefix[$:/StoryList]] -[prefix[$:/HistoryList]]

View File

@ -0,0 +1,2 @@
title: $:/config/Server/ExternalFilters/[all[tiddlers]] -[[$:/isEncrypted]] -[prefix[$:/temp/]] -[prefix[$:/status/]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/library/sjcl.js]] -[[$:/core]]
text: yes

View File

@ -0,0 +1,7 @@
title: $:/config/OfficialPluginLibrary
tags: $:/tags/PluginLibrary
url: https://tiddlywiki.com/library/v5.1.23/index.html
caption: {{$:/language/OfficialPluginLibrary}}
enabled: no
The official plugin library is disabled when using the client-server configuration. Instead, plugins should be installed via the `tiddlywiki.info` file, as described [[here|https://tiddlywiki.com/#Installing%20a%20plugin%20from%20the%20plugin%20library]].

View File

@ -0,0 +1,4 @@
title: $:/plugins/tiddlywiki/multiwikiclient/icon/cloud
tags: $:/tags/Image
<svg class="tc-image-cloud tc-image-button" width="22pt" height="22pt" viewBox="0 0 128 128"><g><path d="M24 103C10.745 103 0 92.255 0 79c0-9.697 5.75-18.05 14.027-21.836A24.787 24.787 0 0114 56c0-13.255 10.745-24 24-24 1.373 0 2.718.115 4.028.337C48.628 24.2 58.707 19 70 19c19.882 0 36 16.118 36 36v.082c12.319 1.016 22 11.336 22 23.918 0 12.239-9.16 22.337-20.999 23.814L107 103H24z"/><path class="tc-image-cloud-idle" d="M57.929 84.698a6 6 0 01-8.485 0L35.302 70.556a6 6 0 118.485-8.485l9.9 9.9L81.97 43.686a6 6 0 018.485 8.486L57.929 84.698z"/><path class="tc-image-cloud-progress tc-animate-rotate-slow" d="M44.8 40a3.6 3.6 0 100 7.2h2.06A23.922 23.922 0 0040 64c0 13.122 10.531 23.785 23.603 23.997L64 88l.001-7.2c-9.171 0-16.626-7.348-16.798-16.477L47.2 64c0-5.165 2.331-9.786 5.999-12.868L53.2 55.6a3.6 3.6 0 107.2 0v-12a3.6 3.6 0 00-3.6-3.6h-12zM64 40v7.2c9.278 0 16.8 7.522 16.8 16.8 0 5.166-2.332 9.787-6 12.869V72.4a3.6 3.6 0 10-7.2 0v12a3.6 3.6 0 003.6 3.6h12a3.6 3.6 0 100-7.2l-2.062.001A23.922 23.922 0 0088 64c0-13.255-10.745-24-24-24z"/></g></svg>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/info-segment
tags: $:/tags/TiddlerInfoSegment
<$reveal type="nomatch" state=<<folded-state>> text="hide" tag="div" retain="yes" animate="yes">
<div class="tc-subtitle">
Bag: <$view tiddler="$:/state/multiwikiclient/tiddlers/bag" index=<<currentTiddler>>>(none)</$view>
</div>
</$reveal>

View File

@ -0,0 +1,341 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiclient/multiwikiclientadaptor.js
type: application/javascript
module-type: syncadaptor
A sync adaptor module for synchronising with MultiWikiServer-compatible servers
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
var CONFIG_HOST_TIDDLER = "$:/config/multiwikiclient/host",
DEFAULT_HOST_TIDDLER = "$protocol$//$host$/",
MWC_STATE_TIDDLER_PREFIX = "$:/state/multiwikiclient/",
BAG_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/bag",
REVISION_STATE_TIDDLER = "$:/state/multiwikiclient/tiddlers/revision",
CONNECTION_STATE_TIDDLER = "$:/state/multiwikiclient/connection",
INCOMING_UPDATES_FILTER_TIDDLER = "$:/config/multiwikiclient/incoming-updates-filter";
var SERVER_NOT_CONNECTED = "NOT CONNECTED",
SERVER_CONNECTING_SSE = "CONNECTING SSE",
SERVER_CONNECTED_SSE = "CONNECTED SSE",
SERVER_POLLING = "SERVER POLLING";
function MultiWikiClientAdaptor(options) {
this.wiki = options.wiki;
this.host = this.getHost();
this.recipe = this.wiki.getTiddlerText("$:/config/multiwikiclient/recipe");
this.last_known_tiddler_id = $tw.utils.parseNumber(this.wiki.getTiddlerText("$:/state/multiwikiclient/recipe/last_tiddler_id","0"));
this.logger = new $tw.utils.Logger("MultiWikiClientAdaptor");
this.isLoggedIn = false;
this.isReadOnly = false;
this.logoutIsAvailable = true;
// Compile the dirty tiddler filter
this.incomingUpdatesFilterFn = this.wiki.compileFilter(this.wiki.getTiddlerText(INCOMING_UPDATES_FILTER_TIDDLER));
this.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
}
MultiWikiClientAdaptor.prototype.setUpdateConnectionStatus = function(status) {
this.serverUpdateConnectionStatus = status;
this.wiki.addTiddler({
title: CONNECTION_STATE_TIDDLER,
text: status
});
};
MultiWikiClientAdaptor.prototype.name = "multiwikiclient";
MultiWikiClientAdaptor.prototype.supportsLazyLoading = true;
MultiWikiClientAdaptor.prototype.setLoggerSaveBuffer = function(loggerForSaving) {
this.logger.setSaveBuffer(loggerForSaving);
};
MultiWikiClientAdaptor.prototype.isReady = function() {
return true;
};
MultiWikiClientAdaptor.prototype.getHost = function() {
var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER),
substitutions = [
{name: "protocol", value: document.location.protocol},
{name: "host", value: document.location.host},
{name: "pathname", value: document.location.pathname}
];
for(var t=0; t<substitutions.length; t++) {
var s = substitutions[t];
text = $tw.utils.replaceString(text,new RegExp("\\$" + s.name + "\\$","mg"),s.value);
}
return text;
};
MultiWikiClientAdaptor.prototype.getTiddlerInfo = function(tiddler) {
var title = tiddler.fields.title,
revision = this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title),
bag = this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title);
if(revision && bag) {
return {
title: title,
revision: revision,
bag: bag
};
} else {
return undefined;
}
};
MultiWikiClientAdaptor.prototype.getTiddlerBag = function(title) {
return this.wiki.extractTiddlerDataItem(BAG_STATE_TIDDLER,title);
};
MultiWikiClientAdaptor.prototype.getTiddlerRevision = function(title) {
return this.wiki.extractTiddlerDataItem(REVISION_STATE_TIDDLER,title);
};
MultiWikiClientAdaptor.prototype.setTiddlerInfo = function(title,revision,bag) {
this.wiki.setText(BAG_STATE_TIDDLER,null,title,bag,{suppressTimestamp: true});
this.wiki.setText(REVISION_STATE_TIDDLER,null,title,revision,{suppressTimestamp: true});
};
MultiWikiClientAdaptor.prototype.removeTiddlerInfo = function(title) {
this.wiki.setText(BAG_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true});
this.wiki.setText(REVISION_STATE_TIDDLER,null,title,undefined,{suppressTimestamp: true});
};
/*
Get the current status of the server connection
*/
MultiWikiClientAdaptor.prototype.getStatus = function(callback) {
// Invoke the callback if present
if(callback) {
callback(
null, // Error
true, // Is logged in
this.username, // Username
false, // Is read only
true // Is anonymous
);
}
};
/*
Get details of changed tiddlers from the server
*/
MultiWikiClientAdaptor.prototype.getUpdatedTiddlers = function(syncer,callback) {
var self = this;
// Do nothing if there's already a connection in progress.
if(this.serverUpdateConnectionStatus !== SERVER_NOT_CONNECTED) {
return callback(null,{
modifications: [],
deletions: []
});
}
// Try to connect a server stream
this.setUpdateConnectionStatus(SERVER_CONNECTING_SSE);
this.connectServerStream({
syncer: syncer,
onerror: function(err) {
self.logger.log("Error connecting SSE stream",err);
// If the stream didn't work, try polling
self.setUpdateConnectionStatus(SERVER_POLLING);
self.pollServer({
callback: function(err,changes) {
self.setUpdateConnectionStatus(SERVER_NOT_CONNECTED);
callback(null,changes);
}
});
},
onopen: function() {
self.setUpdateConnectionStatus(SERVER_CONNECTED_SSE);
// The syncer is expecting a callback but we don't have any data to send
callback(null,{
modifications: [],
deletions: []
});
}
});
};
/*
Attempt to establish an SSE stream with the server and transfer tiddler changes. Options include:
syncer: reference to syncer object used for storing data
onopen: invoked when the stream is successfully opened
onerror: invoked if there is an error
*/
MultiWikiClientAdaptor.prototype.connectServerStream = function(options) {
var self = this;
const eventSource = new EventSource("/recipes/" + this.recipe + "/events?last_known_tiddler_id=" + this.last_known_tiddler_id);
eventSource.onerror = function(event) {
if(options.onerror) {
options.onerror(event);
}
}
eventSource.onopen = function(event) {
if(options.onopen) {
options.onopen(event);
}
}
eventSource.addEventListener("change", function(event) {
const data = $tw.utils.parseJSONSafe(event.data);
if(data) {
console.log("SSE data",data)
if(data.tiddler_id > self.last_known_tiddler_id) {
self.last_known_tiddler_id = data.tiddler_id;
}
if(data.is_deleted) {
self.removeTiddlerInfo(data.title);
delete options.syncer.tiddlerInfo[data.title];
options.syncer.logger.log("Deleting tiddler missing from server:",data.title);
options.syncer.wiki.deleteTiddler(data.title);
options.syncer.processTaskQueue();
} else {
var result = self.incomingUpdatesFilterFn.call(self.wiki,self.wiki.makeTiddlerIterator([data.title]));
if(result.length > 0) {
self.setTiddlerInfo(data.title,data.tiddler_id,data.bag_name);
options.syncer.storeTiddler(data.tiddler);
}
}
}
});
};
/*
Poll the server for changes. Options include:
callback: invoked on completion as (err,changes)
*/
MultiWikiClientAdaptor.prototype.pollServer = function(options) {
var self = this;
$tw.utils.httpRequest({
url: this.host + "recipes/" + this.recipe + "/tiddlers.json",
data: {
last_known_tiddler_id: this.last_known_tiddler_id,
include_deleted: "true"
},
callback: function(err,data) {
// Check for errors
if(err) {
return options.callback(err);
}
var modifications = [],
deletions = [];
var tiddlerInfoArray = $tw.utils.parseJSONSafe(data);
$tw.utils.each(tiddlerInfoArray,function(tiddlerInfo) {
if(tiddlerInfo.tiddler_id > self.last_known_tiddler_id) {
self.last_known_tiddler_id = tiddlerInfo.tiddler_id;
}
if(tiddlerInfo.is_deleted) {
deletions.push(tiddlerInfo.title);
} else {
modifications.push(tiddlerInfo.title);
}
});
// Invoke the callback with the results
options.callback(null,{
modifications: modifications,
deletions: deletions
});
// If Browswer Storage tiddlers were cached on reloading the wiki, add them after sync from server completes in the above callback.
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
$tw.browserStorage.addCachedTiddlers();
}
}
});
};
/*
Save a tiddler and invoke the callback with (err,adaptorInfo,revision)
*/
MultiWikiClientAdaptor.prototype.saveTiddler = function(tiddler,callback,options) {
var self = this;
if(this.isReadOnly || tiddler.fields.title.substr(0,MWC_STATE_TIDDLER_PREFIX.length) === MWC_STATE_TIDDLER_PREFIX) {
return callback(null);
}
$tw.utils.httpRequest({
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(tiddler.fields.title),
type: "PUT",
headers: {
"Content-type": "application/json"
},
data: JSON.stringify(tiddler.getFieldStrings()),
callback: function(err,data,request) {
if(err) {
return callback(err);
}
//If Browser-Storage plugin is present, remove tiddler from local storage after successful sync to the server
if($tw.browserStorage && $tw.browserStorage.isEnabled()) {
$tw.browserStorage.removeTiddlerFromLocalStorage(tiddler.fields.title)
}
// Save the details of the new revision of the tiddler
var revision = request.getResponseHeader("X-Revision-Number"),
bag_name = request.getResponseHeader("X-Bag-Name");
console.log(`Saved ${tiddler.fields.title} with revision ${revision} and bag ${bag_name}`)
// Invoke the callback
self.setTiddlerInfo(tiddler.fields.title,revision,bag_name);
callback(null,{bag: bag_name},revision);
}
});
};
/*
Load a tiddler and invoke the callback with (err,tiddlerFields)
*/
MultiWikiClientAdaptor.prototype.loadTiddler = function(title,callback) {
var self = this;
$tw.utils.httpRequest({
url: this.host + "recipes/" + encodeURIComponent(this.recipe) + "/tiddlers/" + encodeURIComponent(title),
callback: function(err,data,request) {
if(err === 404) {
return callback(null,null);
} else if(err) {
return callback(err);
}
// Invoke the callback
var revision = request.getResponseHeader("X-Revision-Number"),
bag_name = request.getResponseHeader("X-Bag-Name");
self.setTiddlerInfo(title,revision,bag_name);
callback(null,$tw.utils.parseJSONSafe(data));
}
});
};
/*
Delete a tiddler and invoke the callback with (err)
options include:
tiddlerInfo: the syncer's tiddlerInfo for this tiddler
*/
MultiWikiClientAdaptor.prototype.deleteTiddler = function(title,callback,options) {
var self = this;
if(this.isReadOnly) {
return callback(null);
}
// If we don't have a bag it means that the tiddler hasn't been seen by the server, so we don't need to delete it
var bag = this.getTiddlerBag(title);
if(!bag) {
return callback(null,options.tiddlerInfo.adaptorInfo);
}
// Issue HTTP request to delete the tiddler
$tw.utils.httpRequest({
url: this.host + "bags/" + encodeURIComponent(bag) + "/tiddlers/" + encodeURIComponent(title),
type: "DELETE",
callback: function(err,data,request) {
if(err) {
return callback(err);
}
self.removeTiddlerInfo(title);
// Invoke the callback & return null adaptorInfo
callback(null,null);
}
});
};
if($tw.browser && document.location.protocol.substr(0,4) === "http" ) {
exports.adaptorClass = MultiWikiClientAdaptor;
}
})();

View File

@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/multiwikiclient",
"name": "MultiWikiClient",
"description": "Synchronise changes from the browser to TiddlyWiki ~MultiWikiServer",
"list": "readme",
"plugin-priority": 10
}

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/readme
This plugin runs in the browser to synchronise tiddler changes to and from a TiddlyWiki server running ~MultiWikiServer.
This plugin is inert when run under Node.js. Disabling this plugin via the browser can not be undone via the browser since this plugin provides the mechanism to synchronize settings with the server.
Changes made while offline are saved in memory and automatically synchonised with the server when the connection is re-established. However, if the browser tab is closed or another URL is loaded, the in-memory changes will be lost. The [[https://tiddlywiki.com/#BrowserStorage Plugin]] may be added to provide temporary filesystem storage of tiddler changes made while offline and enable them to be synchronised with the server the next time the wiki is loaded in the same browser.

View File

@ -0,0 +1,27 @@
title: $:/plugins/tiddlywiki/multiwikiclient/readonly
tags: [[$:/tags/Stylesheet]]
\define button-selector(title)
button.$title$, .tc-drop-down button.$title$, div.$title$
\end
\define hide-edit-controls()
<$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="yes">
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fclone>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fdelete>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fedit>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-here>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal-here>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fimport>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fmanager>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-image>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-journal>>`,`
<<button-selector tc-btn-\%24\%3A\%2Fcore\%2Fui\%2FButtons\%2Fnew-tiddler>> `{
display: none;
}`
</$reveal>
\end
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
<<hide-edit-controls>>

View File

@ -0,0 +1,7 @@
title: $:/plugins/tiddlywiki/multiwikiclient/save/offline
\import [subfilter{$:/core/config/GlobalImportFilter}]
\define saveTiddlerFilter()
[is[tiddler]] -[[$:/boot/boot.css]] -[prefix[$:/HistoryList]] -[status[pending]plugin-type[import]] -[type[application/javascript]library[yes]] -[[$:/boot/boot.js]] -[[$:/boot/bootprefix.js]] -[[$:/plugins/tiddlywiki/filesystem]] -[[$:/plugins/tiddlywiki/multiwikiclient]] -[prefix[$:/temp/]] +[sort[title]] $(publishFilter)$
\end
{{$:/core/templates/tiddlywiki5.html}}

View File

@ -0,0 +1,26 @@
title: $:/core/ui/Buttons/save-wiki
tags: $:/tags/PageControls
caption: {{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}} Server status
description: Status of synchronisation with server
\whitespace trim
\define config-title()
$:/config/PageControlButtons/Visibility/$(listItem)$
\end
<$button popup=<<qualify "$:/state/popup/save-wiki">> tooltip="Status of synchronisation with server" aria-label="Server status" class=<<tv-config-toolbar-class>> selectedClass="tc-selected">
<span class="tc-dirty-indicator">
<$list filter="[<tv-config-toolbar-icons>match[yes]]">
{{$:/plugins/tiddlywiki/multiwikiclient/icon/cloud}}
</$list>
<$list filter="[<tv-config-toolbar-text>match[yes]]">
<span class="tc-btn-text"><$text text="Server status"/></span>
</$list>
</span>
</$button>
<$reveal state=<<qualify "$:/state/popup/save-wiki">> type="popup" position="belowleft" animate="yes">
<div class="tc-drop-down">
<$list filter="[all[shadows+tiddlers]tag[$:/tags/SyncerDropdown]!has[draft.of]]" variable="listItem">
<$transclude tiddler=<<listItem>>/>
</$list>
</div>
</$reveal>

View File

@ -0,0 +1,5 @@
title: $:/plugins/multiwikiclient/SideBarSegment
tags: $:/tags/SideBarSegment
list-before: $:/core/ui/SideBarSegments/page-controls
MWS Connection Status: {{$:/state/multiwikiclient/connection}}

View File

@ -0,0 +1,44 @@
title: $:/plugins/tiddlywiki/multiwikiclient/styles
tags: [[$:/tags/Stylesheet]]
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
body.tc-dirty span.tc-dirty-indicator svg {
transition: fill 250ms ease-in-out;
}
body .tc-image-cloud-idle {
fill: <<colour background>>;
transition: opacity 250ms ease-in-out;
opacity: 1;
display: unset;
}
body.tc-dirty .tc-image-cloud-idle {
opacity: 0;
display: none;
}
body .tc-image-cloud-progress {
transition: opacity 250ms ease-in-out;
transform-origin: 50% 50%;
transform: rotate(359deg);
animation: animation-rotate-slow 2s infinite linear;
fill: <<colour background>>;
display: none;
opacity: 0;
}
body.tc-dirty .tc-image-cloud-progress {
opacity: 1;
display: unset;
}
@keyframes animation-rotate-slow {
from {
transform: rotate(0deg);
}
to {
transform: scale(359deg);
}
}

View File

@ -0,0 +1,6 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs
tags: $:/tags/SyncerDropdown
<$button message="tm-copy-syncer-logs-to-clipboard" class="tc-btn-invisible">
{{$:/core/images/copy-clipboard}} Copy syncer logs to clipboard
</$button>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<div class="tc-drop-down-info">
You are logged in<$reveal state="$:/status/UserName" type="nomatch" text="" default=""> as <strong><$text text={{$:/status/UserName}}/></strong></$reveal><$reveal state="$:/status/IsReadOnly" type="match" text="yes" default="no"> (read-only)</$reveal>
</div>
<hr/>
</$reveal>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="nomatch" text="yes">
<$button message="tm-login" class="tc-btn-invisible">
{{$:/core/images/unlocked-padlock}} Login
</$button>
</$reveal>

View File

@ -0,0 +1,8 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<$button message="tm-logout" class="tc-btn-invisible">
{{$:/core/images/cancel-button}} Logout
</$button>
</$reveal>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh
tags: $:/tags/SyncerDropdown
<$reveal state="$:/status/IsLoggedIn" type="match" text="yes">
<$button tooltip="Get latest changes from the server" aria-label="Refresh from server" class="tc-btn-invisible">
<$action-sendmessage $message="tm-server-refresh"/>
{{$:/core/images/refresh-button}}<span class="tc-btn-text"><$text text="Get latest changes from the server"/></span>
</$button>
</$reveal>

View File

@ -0,0 +1,9 @@
title: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot
tags: $:/tags/SyncerDropdown
<$button class="tc-btn-invisible">
<$wikify name="site-title" text={{$:/config/SaveWikiButton/Filename}}>
<$action-sendmessage $message="tm-download-file" $param={{$:/config/SaveWikiButton/Template}} filename=<<site-title>>/>
</$wikify>
{{$:/core/images/download-button}} Save snapshot for offline use
</$button>

View File

@ -0,0 +1,2 @@
title: $:/tags/SyncerDropdown
list: $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login-status $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/login $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/refresh $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/logout $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/save-snapshot $:/plugins/tiddlywiki/multiwikiclient/syncer-actions/copy-logs

View File

@ -0,0 +1,2 @@
title: $:/config/MultiWikiServer/AttachmentSizeLimit
text: 204800

View File

@ -0,0 +1,14 @@
title: $:/plugins/tiddlywiki/multiwikiserver/docs
! HTTP API
The ~MultiWikiServer HTTP API provides access to resources hosted by the MWS store. It is based on [[the API of TiddlyWeb|https://tank.peermore.com/tanks/tiddlyweb/HTTP%20API]], first developed in 2008 by Chris Dent.
The design goals of the API are:
* To follow the principles of REST where practical
* To present resources as nouns, not verbs
General points about the design:
* In MWS there are no resources that end with / (except for the root path which is /)

View File

@ -0,0 +1,39 @@
title: $:/plugins/tiddlywiki/multiwikiserver/readme
This plugin extends the TiddlyWiki 5 server running on Node.js to be able to host multiple wikis that can share content or be independent.
Installation
```
git clone https://github.com/Jermolene/TiddlyWiki5.git --branch multi-wiki-support
cd TiddlyWiki5
npm install
```
To start the server:
```
npm start
```
The `npm start` command is a shortcut for the following command:
```
node ./tiddlywiki.js ./editions/multiwikiserver --mws-listen
```
Then visit the administration interface in a browser:
* http://127.0.0.1:8080/
Note that changes are written to the topmost bag in a recipe.
Note that until syncing is improved it is necessary to use "Get latest changes from the server" to speed up propogation of changes.
To run the tests:
```
./bin/test.sh
```

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,49 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-listen.js
type: application/javascript
module-type: command
Listen for HTTP requests and serve tiddlers
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "mws-listen",
synchronous: false,
namedParameterMode: true,
mandatoryParameters: []
};
var Command = function(params,commander,callback) {
var self = this;
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this;
if(!$tw.boot.wikiTiddlersPath) {
$tw.utils.warning("Warning: Wiki folder '" + $tw.boot.wikiPath + "' does not exist or is missing a tiddlywiki.info file");
}
// Set up server
this.server = $tw.mws.serverManager.createServer({
wiki: $tw.wiki,
variables: self.params
});
this.server.listen(null,null,null,{
callback: function() {
self.callback();
}
});
return null;
};
exports.Command = Command;
})();

View File

@ -0,0 +1,93 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-archive.js
type: application/javascript
module-type: command
Command to load archive of recipes, bags and tiddlers from a directory
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "mws-load-archive",
synchronous: true
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this;
// Check parameters
if(this.params.length < 1) {
return "Missing pathname";
}
var archivePath = this.params[0];
loadBackupArchive(archivePath);
return null;
};
function loadBackupArchive(archivePath) {
const fs = require("fs"),
path = require("path");
// Iterate the bags
const bagNames = fs.readdirSync(path.resolve(archivePath,"bags")).filter(filename => filename !== ".DS_Store");
for(const bagFilename of bagNames) {
const bagName = decodeURIComponent(bagFilename);
console.log(`Reading bag ${bagName}`);
const bagInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"bags",bagFilename,"meta.json"),"utf8"));
$tw.mws.store.createBag(bagName,bagInfo.description,bagInfo.accesscontrol);
if(fs.existsSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers"))) {
const tiddlerFilenames = fs.readdirSync(path.resolve(archivePath,"bags",bagFilename,"tiddlers"));
for(const tiddlerFilename of tiddlerFilenames) {
if(tiddlerFilename.endsWith(".json")) {
const tiddlerPath = path.resolve(archivePath,"bags",bagFilename,"tiddlers",tiddlerFilename),
jsonTiddler = fs.readFileSync(tiddlerPath,"utf8"),
tiddler = sanitiseTiddler(JSON.parse(jsonTiddler));
if(tiddler && tiddler.title) {
$tw.mws.store.saveBagTiddler(tiddler,bagName);
} else {
console.log(`Malformed JSON tiddler in file ${tiddlerPath}`);
}
}
}
}
}
// Iterate the recipes
const recipeNames = fs.readdirSync(path.resolve(archivePath,"recipes"));
for(const recipeFilename of recipeNames) {
if(recipeFilename.endsWith(".json")) {
const recipeName = decodeURIComponent(recipeFilename.substring(0,recipeFilename.length - ".json".length));
const jsonInfo = JSON.parse(fs.readFileSync(path.resolve(archivePath,"recipes",recipeFilename),"utf8"));
$tw.mws.store.createRecipe(recipeName,jsonInfo.bag_names,jsonInfo.description,jsonInfo.accesscontrol);
}
}
};
function sanitiseTiddler(tiddler) {
var sanitisedFields = Object.create(null);
for(const fieldName in tiddler) {
const fieldValue = tiddler[fieldName];
let sanitisedValue = "";
if(typeof fieldValue === "string") {
sanitisedValue = fieldValue;
} else if($tw.utils.isDate(fieldValue)) {
sanitisedValue = $tw.utils.stringifyDate(fieldValue);
} else if($tw.utils.isArray(fieldValue)) {
sanitisedValue = $tw.utils.stringifyList(fieldValue);
}
sanitisedFields[fieldName] = sanitisedValue;
}
return sanitisedFields;
}
exports.Command = Command;
})();

View File

@ -0,0 +1,40 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-load-tiddlers.js
type: application/javascript
module-type: command
Command to recursively load a directory of tiddler files into a bag
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "mws-load-tiddlers",
synchronous: true
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this;
// Check parameters
if(this.params.length < 2) {
return "Missing pathname and/or bag name";
}
var tiddlersPath = this.params[0],
bagName = this.params[1];
$tw.mws.store.saveTiddlersFromPath(tiddlersPath,bagName);
return null;
};
exports.Command = Command;
})();

View File

@ -0,0 +1,62 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-save-archive.js
type: application/javascript
module-type: command
Command to load an archive of recipes, bags and tiddlers to a directory
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "mws-save-archive",
synchronous: true
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this;
// Check parameters
if(this.params.length < 1) {
return "Missing pathname";
}
var archivePath = this.params[0];
saveArchive(archivePath);
return null;
};
function saveArchive(archivePath) {
const fs = require("fs"),
path = require("path");
function saveJsonFile(filename,json) {
const filepath = path.resolve(archivePath,filename);
console.log(filepath);
$tw.utils.createFileDirectories(filepath);
fs.writeFileSync(filepath,JSON.stringify(json,null,4));
}
for(const recipeInfo of $tw.mws.store.listRecipes()) {
console.log(`Recipe ${recipeInfo.recipe_name}`);
saveJsonFile(`recipes/${encodeURIComponent(recipeInfo.recipe_name)}.json`,recipeInfo);
}
for(const bagInfo of $tw.mws.store.listBags()) {
console.log(`Bag ${bagInfo.bag_name}`);
saveJsonFile(`bags/${encodeURIComponent(bagInfo.bag_name)}/meta.json`,bagInfo);
for(const title of $tw.mws.store.getBagTiddlers(bagInfo.bag_name)) {
const tiddlerInfo = $tw.mws.store.getBagTiddler(title,bagInfo.bag_name);
saveJsonFile(`bags/${encodeURIComponent(bagInfo.bag_name)}/tiddlers/${encodeURIComponent(title)}.json`,tiddlerInfo.tiddler);
}
}
}
exports.Command = Command;
})();

View File

@ -0,0 +1,158 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/commands/mws-test-server.js
type: application/javascript
module-type: command
Command to test a local or remote MWS server
tiddlywiki editions/multiwikiserver/ --listen --mws-test-server http://127.0.0.1:8080/
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.info = {
name: "mws-test-server",
synchronous: false
};
var Command = function(params,commander,callback) {
this.params = params;
this.commander = commander;
this.callback = callback;
};
Command.prototype.execute = function() {
var self = this;
// Check parameters
if(this.params.length < 1) {
return "Missing URL";
}
// Create the test runner
var urlServer = this.params[0];
var testRunner = new TestRunner(urlServer);
testRunner.runTests(function(failed) {
self.callback(failed ? "MWS Server tests failed" : null);
});
return null;
};
function TestRunner(urlServer) {
const URL = require("node:url").URL;
this.urlServerParsed = new URL(urlServer);
this.httpLibrary = require(this.urlServerParsed.protocol === "https:" ? "https" : "http");
}
TestRunner.prototype.runTests = function(callback) {
const self = this;
let currentTestSpec = 0;
let hasFailed = false;
function runNextTest() {
if(currentTestSpec < testSpecs.length) {
const testSpec = testSpecs[currentTestSpec];
currentTestSpec += 1;
self.runTest(testSpec,function(err) {
if(err) {
hasFailed = true;
console.log(`Failed "${testSpec.description}" with "${err}"`)
}
runNextTest();
});
} else {
if(hasFailed) {
console.log("MWS Server Tests failed");
} else {
console.log("MWS Server Tests succeeded");
}
callback(hasFailed);
}
}
runNextTest();
};
TestRunner.prototype.runTest = function(testSpec,callback) {
const self = this;
console.log(`Running Server Test: ${testSpec.description}`)
if(testSpec.method === "GET" || testSpec.method === "POST") {
const request = this.httpLibrary.request({
protocol: this.urlServerParsed.protocol,
host: this.urlServerParsed.hostname,
port: this.urlServerParsed.port,
path: testSpec.path,
method: testSpec.method,
headers: testSpec.headers
}, function(response) {
if (response.statusCode < 200 || response.statusCode >= 400) {
return callback(`Request failed to ${self.urlServerParsed.toString()} with status code ${response.statusCode} and ${JSON.stringify(response.headers)}`);
}
response.setEncoding("utf8");
let buffer = "";
response.on("data", (chunk) => {
buffer = buffer + chunk;
});
response.on("end", () => {
const jsonData = $tw.utils.parseJSONSafe(buffer,function() {return undefined;});
const testResult = testSpec.expectedResult(jsonData,buffer,response.headers);
callback(testResult ? null : "Test failed");
});
});
request.on("error", (e) => {
console.error(`problem with request: ${e.message}`);
});
if(testSpec.data) {
request.write(testSpec.data);
}
request.end();
} else {
callback("Unknown method");
}
};
const testSpecs = [
{
description: "Check index page",
method: "GET",
path: "/",
headers: {
accept: "*/*"
},
expectedResult: (jsonData,data,headers) => {
return JSON.stringify(data).slice(1,100) === "\\n<!doctype html>\\n<head>\\n\\t<meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html;charset=utf-8\\\" ";
}
},
{
description: "Upload a 1px PNG",
method: "POST",
path: "/bags/bag-alpha/tiddlers/",
headers: {
"Accept": 'application/json',
"Content-Type": 'multipart/form-data; boundary=----WebKitFormBoundaryVR9zv0PFmx9YtpLL',
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
},
data: '------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="file-to-upload"; filename="one-white-pixel.png"\r\nContent-Type: image/png\r\n\r\n\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="tiddler-field-title"\r\n\r\nOne White Pixel\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL\r\nContent-Disposition: form-data; name="tiddler-field-tags"\r\n\r\nimage\r\n------WebKitFormBoundaryVR9zv0PFmx9YtpLL--\r\n',
expectedResult: (jsonData,data) => {
return jsonData["imported-tiddlers"] && $tw.utils.isArray(jsonData["imported-tiddlers"]) && jsonData["imported-tiddlers"][0] === "One White Pixel";
}
},
{
description: "Create a recipe",
method: "POST",
path: "/recipes",
headers: {
"Accept": '*/*',
"Content-Type": 'application/x-www-form-urlencoded',
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
},
data: "recipe_name=Elephants3214234&bag_names=one%20two%20three&description=A%20bag%20of%20elephants",
expectedResult: (jsonData,data,headers) => {
return headers.location === "/";
}
}
];
exports.Command = Command;
})();

View File

@ -0,0 +1,507 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/mws-server.js
type: application/javascript
module-type: library
Serve tiddlers over http
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
if($tw.node) {
var util = require("util"),
fs = require("fs"),
url = require("url"),
path = require("path"),
querystring = require("querystring"),
crypto = require("crypto"),
zlib = require("zlib");
}
/*
A simple HTTP server with regexp-based routes
options: variables - optional hashmap of variables to set (a misnomer - they are really constant parameters)
routes - optional array of routes to use
wiki - reference to wiki object
*/
function Server(options) {
var self = this;
this.routes = options.routes || [];
this.authenticators = options.authenticators || [];
this.wiki = options.wiki;
this.boot = options.boot || $tw.boot;
// Initialise the variables
this.variables = $tw.utils.extend({},this.defaultVariables);
if(options.variables) {
for(var variable in options.variables) {
if(options.variables[variable]) {
this.variables[variable] = options.variables[variable];
}
}
}
// Setup the default required plugins
this.requiredPlugins = this.get("required-plugins").split(',');
// Initialise CSRF
this.csrfDisable = this.get("csrf-disable") === "yes";
// Initialize Gzip compression
this.enableGzip = this.get("gzip") === "yes";
// Initialize browser-caching
this.enableBrowserCache = this.get("use-browser-cache") === "yes";
// Initialise authorization
var authorizedUserName;
if(this.get("username") && this.get("password")) {
authorizedUserName = this.get("username");
} else if(this.get("credentials")) {
authorizedUserName = "(authenticated)";
} else {
authorizedUserName = "(anon)";
}
this.authorizationPrincipals = {
readers: (this.get("readers") || authorizedUserName).split(",").map($tw.utils.trim),
writers: (this.get("writers") || authorizedUserName).split(",").map($tw.utils.trim)
}
if(this.get("admin") || authorizedUserName !== "(anon)") {
this.authorizationPrincipals["admin"] = (this.get("admin") || authorizedUserName).split(',').map($tw.utils.trim)
}
// Load and initialise authenticators
$tw.modules.forEachModuleOfType("authenticator", function(title,authenticatorDefinition) {
// console.log("Loading authenticator " + title);
self.addAuthenticator(authenticatorDefinition.AuthenticatorClass);
});
// Load route handlers
$tw.modules.forEachModuleOfType("mws-route", function(title,routeDefinition) {
self.addRoute(routeDefinition);
});
// Initialise the http vs https
this.listenOptions = null;
this.protocol = "http";
var tlsKeyFilepath = this.get("tls-key"),
tlsCertFilepath = this.get("tls-cert"),
tlsPassphrase = this.get("tls-passphrase");
if(tlsCertFilepath && tlsKeyFilepath) {
this.listenOptions = {
key: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsKeyFilepath),"utf8"),
cert: fs.readFileSync(path.resolve(this.boot.wikiPath,tlsCertFilepath),"utf8"),
passphrase: tlsPassphrase || ''
};
this.protocol = "https";
}
this.transport = require(this.protocol);
// Name the server and init the boot state
this.servername = $tw.utils.transliterateToSafeASCII(this.get("server-name") || this.wiki.getTiddlerText("$:/SiteTitle") || "TiddlyWiki5");
this.boot.origin = this.get("origin")? this.get("origin"): this.protocol+"://"+this.get("host")+":"+this.get("port");
this.boot.pathPrefix = this.get("path-prefix") || "";
}
/*
Send a response to the client. This method checks if the response must be sent
or if the client alrady has the data cached. If that's the case only a 304
response will be transmitted and the browser will use the cached data.
Only requests with status code 200 are considdered for caching.
request: request instance passed to the handler
response: response instance passed to the handler
statusCode: stauts code to send to the browser
headers: response headers (they will be augmented with an `Etag` header)
data: the data to send (passed to the end method of the response instance)
encoding: the encoding of the data to send (passed to the end method of the response instance)
*/
function sendResponse(request,response,statusCode,headers,data,encoding) {
if(this.enableBrowserCache && (statusCode == 200)) {
var hash = crypto.createHash('md5');
// Put everything into the hash that could change and invalidate the data that
// the browser already stored. The headers the data and the encoding.
hash.update(data);
hash.update(JSON.stringify(headers));
if(encoding) {
hash.update(encoding);
}
var contentDigest = hash.digest("hex");
// RFC 7232 section 2.3 mandates for the etag to be enclosed in quotes
headers["Etag"] = '"' + contentDigest + '"';
headers["Cache-Control"] = "max-age=0, must-revalidate";
// Check if any of the hashes contained within the if-none-match header
// matches the current hash.
// If one matches, do not send the data but tell the browser to use the
// cached data.
// We do not implement "*" as it makes no sense here.
var ifNoneMatch = request.headers["if-none-match"];
if(ifNoneMatch) {
var matchParts = ifNoneMatch.split(",").map(function(etag) {
return etag.replace(/^[ "]+|[ "]+$/g, "");
});
if(matchParts.indexOf(contentDigest) != -1) {
response.writeHead(304,headers);
response.end();
return;
}
}
}
/*
If the gzip=yes is set, check if the user agent permits compression. If so,
compress our response if the raw data is bigger than 2k. Compressing less
data is inefficient. Note that we use the synchronous functions from zlib
to stay in the imperative style. The current `Server` doesn't depend on
this, and we may just as well use the async versions.
*/
if(this.enableGzip && (data.length > 2048)) {
var acceptEncoding = request.headers["accept-encoding"] || "";
if(/\bdeflate\b/.test(acceptEncoding)) {
headers["Content-Encoding"] = "deflate";
data = zlib.deflateSync(data);
} else if(/\bgzip\b/.test(acceptEncoding)) {
headers["Content-Encoding"] = "gzip";
data = zlib.gzipSync(data);
}
}
response.writeHead(statusCode,headers);
response.end(data,encoding);
}
function redirect(request,response,statusCode,location) {
response.setHeader("Location",location);
response.statusCode = statusCode;
response.end()
}
/*
Options include:
cbPartStart(headers,name,filename) - invoked when a file starts being received
cbPartChunk(chunk) - invoked when a chunk of a file is received
cbPartEnd() - invoked when a file finishes being received
cbFinished(err) - invoked when the all the form data has been processed
*/
function streamMultipartData(request,options) {
// Check that the Content-Type is multipart/form-data
const contentType = request.headers['content-type'];
if(!contentType.startsWith("multipart/form-data")) {
return options.cbFinished("Expected multipart/form-data content type");
}
// Extract the boundary string from the Content-Type header
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if(!boundaryMatch) {
return options.cbFinished("Missing boundary in multipart/form-data");
}
const boundary = boundaryMatch[1];
const boundaryBuffer = Buffer.from("--" + boundary);
// Initialise
let buffer = Buffer.alloc(0);
let processingPart = false;
// Process incoming chunks
request.on("data", (chunk) => {
// Accumulate the incoming data
buffer = Buffer.concat([buffer, chunk]);
// Loop through any parts within the current buffer
while (true) {
if(!processingPart) {
// If we're not processing a part then we try to find a boundary marker
const boundaryIndex = buffer.indexOf(boundaryBuffer);
if(boundaryIndex === -1) {
// Haven't reached the boundary marker yet, so we should wait for more data
break;
}
// Look for the end of the headers
const endOfHeaders = buffer.indexOf("\r\n\r\n",boundaryIndex + boundaryBuffer.length);
if(endOfHeaders === -1) {
// Haven't reached the end of the headers, so we should wait for more data
break;
}
// Extract and parse headers
const headersPart = Uint8Array.prototype.slice.call(buffer,boundaryIndex + boundaryBuffer.length,endOfHeaders).toString();
const currentHeaders = {};
headersPart.split("\r\n").forEach(headerLine => {
const [key, value] = headerLine.split(": ");
currentHeaders[key.toLowerCase()] = value;
});
// Parse the content disposition header
const contentDisposition = {
name: null,
filename: null
};
if(currentHeaders["content-disposition"]) {
// Split the content-disposition header into semicolon-delimited parts
const parts = currentHeaders["content-disposition"].split(";").map(part => part.trim());
// Iterate over each part to extract name and filename if they exist
parts.forEach(part => {
if(part.startsWith("name=")) {
// Remove "name=" and trim quotes
contentDisposition.name = part.substring(6,part.length - 1);
} else if(part.startsWith("filename=")) {
// Remove "filename=" and trim quotes
contentDisposition.filename = part.substring(10,part.length - 1);
}
});
}
processingPart = true;
options.cbPartStart(currentHeaders,contentDisposition.name,contentDisposition.filename);
// Slice the buffer to the next part
buffer = Uint8Array.prototype.slice.call(buffer,endOfHeaders + 4);
} else {
const boundaryIndex = buffer.indexOf(boundaryBuffer);
if(boundaryIndex >= 0) {
// Return the part up to the boundary minus the terminating LF CR
options.cbPartChunk(Uint8Array.prototype.slice.call(buffer,0,boundaryIndex - 2));
options.cbPartEnd();
processingPart = false;
buffer = Uint8Array.prototype.slice.call(buffer,boundaryIndex);
} else {
// Return the rest of the buffer
options.cbPartChunk(buffer);
// Reset the buffer and wait for more data
buffer = Buffer.alloc(0);
break;
}
}
}
});
// All done
request.on("end", () => {
options.cbFinished(null);
});
}
/*
Make an etag. Options include:
bag_name:
tiddler_id:
*/
function makeTiddlerEtag(options) {
if(options.bag_name || options.tiddler_id) {
return "\"tiddler:" + options.bag_name + "/" + options.tiddler_id + "\"";
} else {
throw "Missing bag_name or tiddler_id";
}
}
Server.prototype.defaultVariables = {
port: "8080",
host: "127.0.0.1",
"required-plugins": "$:/plugins/tiddlywiki/filesystem,$:/plugins/tiddlywiki/tiddlyweb",
"root-tiddler": "$:/core/save/all",
"root-render-type": "text/plain",
"root-serve-type": "text/html",
"tiddler-render-type": "text/html",
"tiddler-render-template": "$:/core/templates/server/static.tiddler.html",
"system-tiddler-render-type": "text/plain",
"system-tiddler-render-template": "$:/core/templates/wikified-tiddler",
"debug-level": "none",
"gzip": "no",
"use-browser-cache": "no"
};
Server.prototype.get = function(name) {
return this.variables[name];
};
Server.prototype.addRoute = function(route) {
this.routes.push(route);
};
Server.prototype.addAuthenticator = function(AuthenticatorClass) {
// Instantiate and initialise the authenticator
var authenticator = new AuthenticatorClass(this),
result = authenticator.init();
if(typeof result === "string") {
$tw.utils.error("Error: " + result);
} else if(result) {
// Only use the authenticator if it initialised successfully
this.authenticators.push(authenticator);
}
};
Server.prototype.findMatchingRoute = function(request,state) {
for(var t=0; t<this.routes.length; t++) {
var potentialRoute = this.routes[t],
pathRegExp = potentialRoute.path,
pathname = state.urlInfo.pathname,
match;
if(state.pathPrefix) {
if(pathname.substr(0,state.pathPrefix.length) === state.pathPrefix) {
pathname = pathname.substr(state.pathPrefix.length) || "/";
match = potentialRoute.path.exec(pathname);
} else {
match = false;
}
} else {
match = potentialRoute.path.exec(pathname);
}
// Allow POST as a synonym for PUT because HTML doesn't allow PUT forms
if(match && (request.method === potentialRoute.method || (request.method === "POST" && potentialRoute.method === "PUT"))) {
state.params = [];
for(var p=1; p<match.length; p++) {
state.params.push(match[p]);
}
return potentialRoute;
}
}
return null;
};
Server.prototype.methodMappings = {
"GET": "readers",
"OPTIONS": "readers",
"HEAD": "readers",
"PUT": "writers",
"POST": "writers",
"DELETE": "writers"
};
/*
Check whether a given user is authorized for the specified authorizationType ("readers" or "writers"). Pass null or undefined as the username to check for anonymous access
*/
Server.prototype.isAuthorized = function(authorizationType,username) {
var principals = this.authorizationPrincipals[authorizationType] || [];
return principals.indexOf("(anon)") !== -1 || (username && (principals.indexOf("(authenticated)") !== -1 || principals.indexOf(username) !== -1));
}
Server.prototype.requestHandler = function(request,response,options) {
options = options || {};
const queryString = require("querystring");
// Compose the state object
var self = this;
var state = {};
state.wiki = options.wiki || self.wiki;
state.boot = options.boot || self.boot;
state.server = self;
state.urlInfo = url.parse(request.url);
state.queryParameters = querystring.parse(state.urlInfo.query);
state.pathPrefix = options.pathPrefix || this.get("path-prefix") || "";
state.sendResponse = sendResponse.bind(self,request,response);
state.redirect = redirect.bind(self,request,response);
state.streamMultipartData = streamMultipartData.bind(self,request);
state.makeTiddlerEtag = makeTiddlerEtag.bind(self);
// Get the principals authorized to access this resource
state.authorizationType = options.authorizationType || this.methodMappings[request.method] || "readers";
// Check whether anonymous access is granted
state.allowAnon = this.isAuthorized(state.authorizationType,null);
// Authenticate with the first active authenticator
if(this.authenticators.length > 0) {
if(!this.authenticators[0].authenticateRequest(request,response,state)) {
// Bail if we failed (the authenticator will have sent the response)
return;
}
}
// Authorize with the authenticated username
if(!this.isAuthorized(state.authorizationType,state.authenticatedUsername)) {
response.writeHead(401,"'" + state.authenticatedUsername + "' is not authorized to access '" + this.servername + "'");
response.end();
return;
}
// Find the route that matches this path
var route = self.findMatchingRoute(request,state);
// Optionally output debug info
if(self.get("debug-level") !== "none") {
console.log("Request path:",JSON.stringify(state.urlInfo));
console.log("Request headers:",JSON.stringify(request.headers));
console.log("authenticatedUsername:",state.authenticatedUsername);
}
// Return a 404 if we didn't find a route
if(!route) {
response.writeHead(404);
response.end();
return;
}
// If this is a write, check for the CSRF header unless globally disabled, or disabled for this route
if(!this.csrfDisable && !route.csrfDisable && state.authorizationType === "writers" && request.headers["x-requested-with"] !== "TiddlyWiki") {
response.writeHead(403,"'X-Requested-With' header required to login to '" + this.servername + "'");
response.end();
return;
}
// Receive the request body if necessary and hand off to the route handler
if(route.bodyFormat === "stream" || request.method === "GET" || request.method === "HEAD") {
// Let the route handle the request stream itself
route.handler(request,response,state);
} else if(route.bodyFormat === "string" || route.bodyFormat === "www-form-urlencoded" || !route.bodyFormat) {
// Set the encoding for the incoming request
request.setEncoding("utf8");
var data = "";
request.on("data",function(chunk) {
data += chunk.toString();
});
request.on("end",function() {
if(route.bodyFormat === "www-form-urlencoded") {
data = queryString.parse(data);
}
state.data = data;
route.handler(request,response,state);
});
} else if(route.bodyFormat === "buffer") {
var data = [];
request.on("data",function(chunk) {
data.push(chunk);
});
request.on("end",function() {
state.data = Buffer.concat(data);
route.handler(request,response,state);
})
} else {
response.writeHead(400,"Invalid bodyFormat " + route.bodyFormat + " in route " + route.method + " " + route.path.source);
response.end();
}
};
/*
Listen for requests
port: optional port number (falls back to value of "port" variable)
host: optional host address (falls back to value of "host" variable)
prefix: optional prefix (falls back to value of "path-prefix" variable)
callback: optional callback(err) to be invoked when the listener is up and running
*/
Server.prototype.listen = function(port,host,prefix,options) {
var self = this;
// Handle defaults for port and host
port = port || this.get("port");
host = host || this.get("host");
prefix = prefix || this.get("path-prefix") || "";
// Check for the port being a string and look it up as an environment variable
if(parseInt(port,10).toString() !== port) {
port = process.env[port] || 8080;
}
// Warn if required plugins are missing
var missing = [];
for (var index=0; index<this.requiredPlugins.length; index++) {
if(!this.wiki.getTiddler(this.requiredPlugins[index])) {
missing.push(this.requiredPlugins[index]);
}
}
if(missing.length > 0) {
var error = "Warning: Plugin(s) required for client-server operation are missing.\n"+
"\""+ missing.join("\", \"")+"\"";
$tw.utils.warning(error);
}
// Create the server
var server = this.transport.createServer(this.listenOptions || {},function(request,response,options) {
if(self.get("debug-level") !== "none") {
var start = $tw.utils.timer();
response.on("finish",function() {
console.log("Response time:",request.method,request.url,$tw.utils.timer() - start);
});
}
self.requestHandler(request,response,options);
});
// Display the port number after we've started listening (the port number might have been specified as zero, in which case we will get an assigned port)
server.on("listening",function() {
// Stop listening when we get the "th-quit" hook
$tw.hooks.addHook("th-quit",function() {
server.close();
});
// Log listening details
var address = server.address(),
url = self.protocol + "://" + (address.family === "IPv6" ? "[" + address.address + "]" : address.address) + ":" + address.port + prefix;
$tw.utils.log("Serving on " + url,"brown/orange");
$tw.utils.log("(press ctrl-C to exit)","red");
if(options.callback) {
options.callback(null);
}
});
// Listen
return server.listen(port,host);
};
exports.Server = Server;
})();

View File

@ -0,0 +1,36 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/delete-bag-tiddler.js
type: application/javascript
module-type: mws-route
DELETE /bags/:bag_name/tiddler/:title
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "DELETE";
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(bag_name) {
var result = $tw.mws.store.deleteTiddler(title,bag_name);
response.writeHead(204, "OK", {
Etag: state.makeTiddlerEtag(result),
"Content-Type": "text/plain"
});
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,38 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler-blob.js
type: application/javascript
module-type: mws-route
GET /bags/:bag_name/tiddler/:title/blob
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/([^\/]+)\/blob$/;
exports.handler = function(request,response,state) {
// Get the parameters
const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]);
if(bag_name) {
const result = $tw.mws.store.getBagTiddlerStream(title,bag_name);
if(result) {
response.writeHead(200, "OK",{
Etag: state.makeTiddlerEtag(result),
"Content-Type": result.type,
});
result.stream.pipe(response);
return;
}
}
response.writeHead(404);
response.end();
};
}());

View File

@ -0,0 +1,68 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag-tiddler.js
type: application/javascript
module-type: mws-route
GET /bags/:bag_name/tiddler/:title
Parameters:
fallback=<url> // Optional redirect if the tiddler is not found
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
const bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
tiddlerInfo = $tw.mws.store.getBagTiddler(title,bag_name);
if(tiddlerInfo && tiddlerInfo.tiddler) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{
Etag: state.makeTiddlerEtag(tiddlerInfo),
"Content-Type": "application/json"
},JSON.stringify(tiddlerInfo.tiddler),"utf8");
return;
} else {
// This is not a JSON API request, we should return the raw tiddler content
const result = $tw.mws.store.getBagTiddlerStream(title,bag_name);
if(result) {
response.writeHead(200, "OK",{
Etag: state.makeTiddlerEtag(result),
"Content-Type": result.type
});
result.stream.pipe(response);
return;
} else {
response.writeHead(404);
response.end();
return;
}
}
} else {
// Redirect to fallback URL if tiddler not found
if(state.queryParameters.fallback) {
response.writeHead(302, "OK",{
"Location": state.queryParameters.fallback
});
response.end();
return;
} else {
response.writeHead(404);
response.end();
return;
}
}
};
}());

View File

@ -0,0 +1,55 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-bag.js
type: application/javascript
module-type: mws-route
GET /bags/:bag_name/
GET /bags/:bag_name
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/bags\/([^\/]+)(\/?)$/;
exports.handler = function(request,response,state) {
// Redirect if there is no trailing slash. We do this so that the relative URL specified in the upload form works correctly
if(state.params[1] !== "/") {
state.redirect(301,state.urlInfo.path + "/");
return;
}
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
bagTiddlers = bag_name && $tw.mws.store.getBagTiddlers(bag_name);
if(bag_name && bagTiddlers) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(bagTiddlers),"utf8");
} else {
// This is not a JSON API request, we should return the raw tiddler content
response.writeHead(200, "OK",{
"Content-Type": "text/html"
});
var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{
variables: {
"page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-bag",
"bag-name": bag_name,
"bag-titles": JSON.stringify(bagTiddlers.map(bagTiddler => bagTiddler.title)),
"bag-tiddlers": JSON.stringify(bagTiddlers)
}
});
response.write(html);
response.end();
}
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,45 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-index.js
type: application/javascript
module-type: mws-route
GET /?show_system=true
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/$/;
exports.handler = function(request,response,state) {
// Get the bag and recipe information
var bagList = $tw.mws.store.listBags(),
recipeList = $tw.mws.store.listRecipes();
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipes),"utf8");
} else {
// This is not a JSON API request, we should return the raw tiddler content
response.writeHead(200, "OK",{
"Content-Type": "text/html"
});
// Render the html
var html = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/plugins/tiddlywiki/multiwikiserver/templates/page",{
variables: {
"show-system": state.queryParameters.show_system || "off",
"page-content": "$:/plugins/tiddlywiki/multiwikiserver/templates/get-index",
"bag-list": JSON.stringify(bagList),
"recipe-list": JSON.stringify(recipeList)
}
});
response.write(html);
response.end();
}
};
}());

View File

@ -0,0 +1,88 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-events.js
type: application/javascript
module-type: mws-route
GET /recipes/:recipe_name/events
headers:
Last-Event-ID:
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
const SSE_HEARTBEAT_INTERVAL_MS = 10 * 1000;
exports.method = "GET";
exports.path = /^\/recipes\/([^\/]+)\/events$/;
exports.handler = function(request,response,state) {
// Get the parameters
const recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
let last_known_tiddler_id = 0;
if(request.headers["Last-Event-ID"]) {
last_known_tiddler_id = $tw.utils.parseNumber(request.headers["Last-Event-ID"]);
} else if(state.queryParameters.last_known_tiddler_id) {
last_known_tiddler_id = $tw.utils.parseNumber(state.queryParameters.last_known_tiddler_id);
}
if(recipe_name) {
// Start streaming the response
response.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
// Setup the heartbeat timer
var heartbeatTimer = setInterval(function() {
response.write(':keep-alive\n\n');
},SSE_HEARTBEAT_INTERVAL_MS);
// Method to get changed tiddler events and send to the client
function sendUpdates() {
// Get the tiddlers in the recipe since the last known tiddler_id
var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{
include_deleted: true,
last_known_tiddler_id: last_known_tiddler_id
});
// Send to the client
if(recipeTiddlers) {
for(let index = recipeTiddlers.length-1; index>=0; index--) {
const tiddlerInfo = recipeTiddlers[index];
if(tiddlerInfo.tiddler_id > last_known_tiddler_id) {
last_known_tiddler_id = tiddlerInfo.tiddler_id;
}
response.write(`event: change\n`)
let data = tiddlerInfo;
if(!tiddlerInfo.is_deleted) {
const tiddler = $tw.mws.store.getRecipeTiddler(tiddlerInfo.title,recipe_name);
if(tiddler) {
data = $tw.utils.extend({},data,{tiddler: tiddler.tiddler})
}
}
response.write(`data: ${JSON.stringify(data)}\n`);
response.write(`id: ${tiddlerInfo.tiddler_id}\n`)
response.write(`\n`);
}
}
}
// Send current and future changes
sendUpdates();
$tw.mws.store.addEventListener("change",sendUpdates);
// Clean up when the connection closes
response.on("close",function () {
clearInterval(heartbeatTimer);
$tw.mws.store.removeEventListener("change",sendUpdates);
});
return;
}
// Fail if something went wrong
response.writeHead(404);
response.end();
};
}());

View File

@ -0,0 +1,65 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddler.js
type: application/javascript
module-type: mws-route
GET /recipes/:recipe_name/tiddler/:title
Parameters:
fallback=<url> // Optional redirect if the tiddler is not found
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
tiddlerInfo = $tw.mws.store.getRecipeTiddler(title,recipe_name);
if(tiddlerInfo && tiddlerInfo.tiddler) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{
"X-Revision-Number": tiddlerInfo.tiddler_id,
"X-Bag-Name": tiddlerInfo.bag_name,
Etag: state.makeTiddlerEtag(tiddlerInfo),
"Content-Type": "application/json"
},JSON.stringify(tiddlerInfo.tiddler),"utf8");
return;
} else {
// This is not a JSON API request, we should return the raw tiddler content
var type = tiddlerInfo.tiddler.type || "text/plain";
response.writeHead(200, "OK",{
Etag: state.makeTiddlerEtag(tiddlerInfo),
"Content-Type": type
});
response.write(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding);
response.end();;
return;
}
} else {
// Redirect to fallback URL if tiddler not found
if(state.queryParameters.fallback) {
response.writeHead(302, "OK",{
"Location": state.queryParameters.fallback
});
response.end();
return;
} else {
response.writeHead(404);
response.end();
return;
}
}
};
}());

View File

@ -0,0 +1,39 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-recipe-tiddlers-json.js
type: application/javascript
module-type: mws-route
GET /recipes/:recipe_name/tiddlers.json?last_known_tiddler_id=:last_known_tiddler_id&include_deleted=true|false
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/recipes\/([^\/]+)\/tiddlers.json$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
if(recipe_name) {
// Get the tiddlers in the recipe, optionally since the specified last known tiddler_id
var recipeTiddlers = $tw.mws.store.getRecipeTiddlers(recipe_name,{
include_deleted: state.queryParameters.include_deleted === "true",
last_known_tiddler_id: state.queryParameters.last_known_tiddler_id
});
if(recipeTiddlers) {
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify(recipeTiddlers),"utf8");
return;
}
}
// Fail if something went wrong
response.writeHead(404);
response.end();
};
}());

View File

@ -0,0 +1,52 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-system.js
type: application/javascript
module-type: mws-route
Retrieves a system file. System files are stored in configuration tiddlers with the following fields:
* title: "$:/plugins/tiddlywiki/multiwikiserver/system-files/" suffixed with the name of the file
* tags: tagged $:/tags/MWS/SystemFile or $:/tags/MWS/SystemFileWikified
* system-file-type: optionally specify the MIME type that should be returned for the file
GET /.system/:filename
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/\.system\/(.+)$/;
const SYSTEM_FILE_TITLE_PREFIX = "$:/plugins/tiddlywiki/multiwikiserver/system-files/";
exports.handler = function(request,response,state) {
// Get the parameters
const filename = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = SYSTEM_FILE_TITLE_PREFIX + filename,
tiddler = $tw.wiki.getTiddler(title),
isSystemFile = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFile"),
isSystemFileWikified = tiddler && tiddler.hasTag("$:/tags/MWS/SystemFileWikified");
if(tiddler && (isSystemFile || isSystemFileWikified)) {
let text = tiddler.fields.text || "";
const type = tiddler.fields["system-file-type"] || tiddler.fields.type || "text/plain",
encoding = ($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding;
if(isSystemFileWikified) {
text = $tw.wiki.renderTiddler("text/plain",title);
}
response.writeHead(200, "OK",{
"Content-Type": type
});
response.write(text,encoding);
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,93 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/get-wiki.js
type: application/javascript
module-type: mws-route
GET /wiki/:recipe_name
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "GET";
exports.path = /^\/wiki\/([^\/]+)$/;
exports.handler = function(request,response,state) {
// Get the recipe name from the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
recipeTiddlers = recipe_name && $tw.mws.store.getRecipeTiddlers(recipe_name);
// Check request is valid
if(recipe_name && recipeTiddlers) {
// Start the response
response.writeHead(200, "OK",{
"Content-Type": "text/html"
});
// Get the tiddlers in the recipe
// Render the template
var template = $tw.mws.store.adminWiki.renderTiddler("text/plain","$:/core/templates/tiddlywiki5.html",{
variables: {
saveTiddlerFilter: `
$:/boot/boot.css
$:/boot/boot.js
$:/boot/bootprefix.js
$:/core
$:/library/sjcl.js
$:/plugins/tiddlywiki/multiwikiclient
$:/themes/tiddlywiki/snowwhite
$:/themes/tiddlywiki/vanilla
`
}
});
// Splice in our tiddlers
var marker = `<` + `script class="tiddlywiki-tiddler-store" type="application/json">[`,
markerPos = template.indexOf(marker);
if(markerPos === -1) {
throw new Error("Cannot find tiddler store in template");
}
function writeTiddler(tiddlerFields) {
response.write(JSON.stringify(tiddlerFields).replace(/</g,"\\u003c"));
response.write(",\n");
}
response.write(template.substring(0,markerPos + marker.length));
const bagInfo = {},
revisionInfo = {};
$tw.utils.each(recipeTiddlers,function(recipeTiddlerInfo) {
var result = $tw.mws.store.getRecipeTiddler(recipeTiddlerInfo.title,recipe_name);
if(result) {
bagInfo[result.tiddler.title] = result.bag_name;
revisionInfo[result.tiddler.title] = result.tiddler_id.toString();
writeTiddler(result.tiddler);
}
});
writeTiddler({
title: "$:/state/multiwikiclient/tiddlers/bag",
text: JSON.stringify(bagInfo),
type: "application/json"
});
writeTiddler({
title: "$:/state/multiwikiclient/tiddlers/revision",
text: JSON.stringify(revisionInfo),
type: "application/json"
});
writeTiddler({
title: "$:/config/multiwikiclient/recipe",
text: recipe_name
});
writeTiddler({
title: "$:/state/multiwikiclient/recipe/last_tiddler_id",
text: ($tw.mws.store.getRecipeLastTiddlerId(recipe_name) || 0).toString()
});
response.write(template.substring(markerPos + marker.length))
// Finish response
response.end();
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,70 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag-tiddlers.js
type: application/javascript
module-type: mws-route
POST /bags/:bag_name/tiddlers/
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "POST";
exports.path = /^\/bags\/([^\/]+)\/tiddlers\/$/;
exports.bodyFormat = "stream";
exports.csrfDisable = true;
exports.handler = function(request,response,state) {
const path = require("path"),
fs = require("fs"),
processIncomingStream = require("$:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js").processIncomingStream;
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]);
// Process the incoming data
processIncomingStream({
store: $tw.mws.store,
state: state,
response: response,
bag_name: bag_name,
callback: function(err,results) {
// If application/json is requested then this is an API request, and gets the response in JSON
if(request.headers.accept && request.headers.accept.indexOf("application/json") !== -1) {
state.sendResponse(200,{"Content-Type": "application/json"},JSON.stringify({
"imported-tiddlers": results
}));
} else {
response.writeHead(200, "OK",{
"Content-Type": "text/html"
});
response.write(`
<!doctype html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
</head>
<body>
`);
// Render the html
var html = $tw.mws.store.adminWiki.renderTiddler("text/html","$:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers",{
variables: {
"bag-name": bag_name,
"imported-titles": JSON.stringify(results)
}
});
response.write(html);
response.write(`
</body>
</html>
`);
response.end();
}
}
});
};
}());

View File

@ -0,0 +1,50 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-bag.js
type: application/javascript
module-type: mws-route
POST /bags
Parameters:
bag_name
description
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "POST";
exports.path = /^\/bags$/;
exports.bodyFormat = "www-form-urlencoded";
exports.csrfDisable = true;
exports.handler = function(request,response,state) {
if(state.data.bag_name) {
const result = $tw.mws.store.createBag(state.data.bag_name,state.data.description);
if(!result) {
state.sendResponse(302,{
"Content-Type": "text/plain",
"Location": "/"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
});
}
};
}());

View File

@ -0,0 +1,51 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/post-recipe.js
type: application/javascript
module-type: mws-route
POST /recipes
Parameters:
recipe_name
description
bag_names: space separated list of bags
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "POST";
exports.path = /^\/recipes$/;
exports.bodyFormat = "www-form-urlencoded";
exports.csrfDisable = true;
exports.handler = function(request,response,state) {
if(state.data.recipe_name && state.data.bag_names) {
const result = $tw.mws.store.createRecipe(state.data.recipe_name,$tw.utils.parseStringArray(state.data.bag_names),state.data.description);
if(!result) {
state.sendResponse(302,{
"Content-Type": "text/plain",
"Location": "/"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
});
}
};
}());

View File

@ -0,0 +1,42 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-bag.js
type: application/javascript
module-type: mws-route
PUT /bags/:bag_name
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "PUT";
exports.path = /^\/bags\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var bag_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
data = $tw.utils.parseJSONSafe(state.data);
if(bag_name && data) {
const result = $tw.mws.store.createBag(bag_name,data.description);
if(!result) {
state.sendResponse(204,{
"Content-Type": "text/plain"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,45 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe-tiddler.js
type: application/javascript
module-type: mws-route
PUT /recipes/:recipe_name/tiddlers/:title
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "PUT";
exports.path = /^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
title = $tw.utils.decodeURIComponentSafe(state.params[1]),
fields = $tw.utils.parseJSONSafe(state.data);
if(recipe_name && title === fields.title) {
var result = $tw.mws.store.saveRecipeTiddler(fields,recipe_name);
if(result) {
response.writeHead(204, "OK",{
"X-Revision-Number": result.tiddler_id.toString(),
"X-Bag-Name": result.bag_name,
Etag: state.makeTiddlerEtag(result),
"Content-Type": "text/plain"
});
} else {
response.writeHead(400);
}
response.end();
return;
}
// Fail if something went wrong
response.writeHead(404);
response.end();
};
}());

View File

@ -0,0 +1,42 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/handlers/put-recipe.js
type: application/javascript
module-type: mws-route
PUT /recipes/:recipe_name
\*/
(function() {
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
exports.method = "PUT";
exports.path = /^\/recipes\/(.+)$/;
exports.handler = function(request,response,state) {
// Get the parameters
var recipe_name = $tw.utils.decodeURIComponentSafe(state.params[0]),
data = $tw.utils.parseJSONSafe(state.data);
if(recipe_name && data) {
const result = $tw.mws.store.createRecipe(recipe_name,data.bag_names,data.description);
if(!result) {
state.sendResponse(204,{
"Content-Type": "text/plain"
});
} else {
state.sendResponse(400,{
"Content-Type": "text/plain"
},
result.message,
"utf8");
}
} else {
response.writeHead(404);
response.end();
}
};
}());

View File

@ -0,0 +1,100 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/routes/helpers/multipart-forms.js
type: application/javascript
module-type: library
A function that handles an incoming multipart/form-data stream, streaming the data to temporary files
in the store/inbox folder. Once the data is received, it imports any tiddlers and invokes a callback.
\*/
(function() {
/*
Process an incoming new multipart/form-data stream. Options include:
store - tiddler store
state - provided by server.js
response - provided by server.js
bag_name - name of bag to write to
callback - invoked as callback(err,results). Results is an array of titles of imported tiddlers
*/
exports.processIncomingStream = function(options) {
const self = this;
const path = require("path"),
fs = require("fs");
// Process the incoming data
const inboxName = $tw.utils.stringifyDate(new Date());
const inboxPath = path.resolve(options.store.attachmentStore.storePath,"inbox",inboxName);
$tw.utils.createDirectory(inboxPath);
let fileStream = null; // Current file being written
let hash = null; // Accumulating hash of current part
let length = 0; // Accumulating length of current part
const parts = []; // Array of {name:, headers:, value:, hash:} and/or {name:, filename:, headers:, inboxFilename:, hash:}
options.state.streamMultipartData({
cbPartStart: function(headers,name,filename) {
const part = {
name: name,
filename: filename,
headers: headers
};
if(filename) {
const inboxFilename = (parts.length).toString();
part.inboxFilename = path.resolve(inboxPath,inboxFilename);
fileStream = fs.createWriteStream(part.inboxFilename);
} else {
part.value = "";
}
hash = new $tw.sjcl.hash.sha256();
length = 0;
parts.push(part)
},
cbPartChunk: function(chunk) {
if(fileStream) {
fileStream.write(chunk);
} else {
parts[parts.length - 1].value += chunk;
}
length = length + chunk.length;
hash.update(chunk);
},
cbPartEnd: function() {
if(fileStream) {
fileStream.end();
}
fileStream = null;
parts[parts.length - 1].hash = $tw.sjcl.codec.hex.fromBits(hash.finalize()).slice(0,64).toString();
hash = null;
},
cbFinished: function(err) {
if(err) {
return options.callback(err);
} else {
const partFile = parts.find(part => part.name === "file-to-upload" && !!part.filename);
if(!partFile) {
return state.sendResponse(400, {"Content-Type": "text/plain"},"Missing file to upload");
}
const type = partFile.headers["content-type"];
const tiddlerFields = {
title: partFile.filename,
type: type
};
for(const part of parts) {
const tiddlerFieldPrefix = "tiddler-field-";
if(part.name.startsWith(tiddlerFieldPrefix)) {
tiddlerFields[part.name.slice(tiddlerFieldPrefix.length)] = part.value.trim();
}
}
options.store.saveBagTiddlerWithAttachment(tiddlerFields,options.bag_name,{
filepath: partFile.inboxFilename,
type: type,
hash: partFile.hash
});
$tw.utils.deleteDirectory(inboxPath);
options.callback(null,[tiddlerFields.title]);
}
}
});
};
})();

View File

@ -0,0 +1,189 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/startup.js
type: application/javascript
module-type: startup
Multi wiki server initialisation
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
// Export name and synchronous status
exports.name = "multiwikiserver";
exports.platforms = ["node"];
exports.before = ["story"];
exports.synchronous = true;
exports.startup = function() {
const store = setupStore();
loadStore(store);
$tw.mws = {
store: store,
serverManager: new ServerManager({
store: store
})
};
}
function setupStore() {
const path = require("path");
// Create and initialise the attachment store and the tiddler store
const AttachmentStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/attachments.js").AttachmentStore,
attachmentStore = new AttachmentStore({
storePath: path.resolve($tw.boot.wikiPath,"store/")
}),
SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore,
store = new SqlTiddlerStore({
databasePath: path.resolve($tw.boot.wikiPath,"store/database.sqlite"),
engine: $tw.wiki.getTiddlerText("$:/config/MultiWikiServer/Engine","better"), // better || wasm
attachmentStore: attachmentStore
});
return store;
}
function loadStore(store) {
const path = require("path"),
fs = require("fs");
// Performance timing
console.time("mws-initial-load");
// Copy plugins
var makePluginBagName = function(type,publisher,name) {
return "$:/" + type + "/" + (publisher ? publisher + "/" : "") + name;
},
savePlugin = function(pluginFields,type,publisher,name) {
const bagName = makePluginBagName(type,publisher,name);
const result = store.createBag(bagName,pluginFields.description || "(no description)",{allowPrivilegedCharacters: true});
if(result) {
console.log(`Error creating plugin bag ${bagname}: ${JSON.stringify(result)}`);
}
console.log(`saveBagTiddler of ${pluginFields.title} to ${bagName}`);
store.saveBagTiddler(pluginFields,bagName);
},
collectPlugins = function(folder,type,publisher) {
var pluginFolders = $tw.utils.getSubdirectories(folder) || [];
for(var p=0; p<pluginFolders.length; p++) {
const pluginFolderName = pluginFolders[p];
if(!$tw.boot.excludeRegExp.test(pluginFolderName)) {
var pluginFields = $tw.loadPluginFolder(path.resolve(folder,pluginFolderName));
if(pluginFields && pluginFields.title) {
savePlugin(pluginFields,type,publisher,pluginFolderName);
}
}
}
},
collectPublisherPlugins = function(folder,type) {
var publisherFolders = $tw.utils.getSubdirectories(folder) || [];
for(var t=0; t<publisherFolders.length; t++) {
const publisherFolderName = publisherFolders[t];
if(!$tw.boot.excludeRegExp.test(publisherFolderName)) {
collectPlugins(path.resolve(folder,publisherFolderName),type,publisherFolderName);
}
}
};
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.pluginsPath,$tw.config.pluginsEnvVar),function(folder) {
collectPublisherPlugins(folder,"plugin");
});
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.themesPath,$tw.config.themesEnvVar),function(folder) {
collectPublisherPlugins(folder,"theme");
});
$tw.utils.each($tw.getLibraryItemSearchPaths($tw.config.languagesPath,$tw.config.languagesEnvVar),function(folder) {
collectPlugins(folder,"language");
});
// Copy TiddlyWiki core editions
function copyEdition(options) {
// Read the tiddlywiki.info file
const wikiInfoPath = path.resolve($tw.boot.corePath,$tw.config.editionsPath,options.wikiPath,$tw.config.wikiInfo);
let wikiInfo;
if(fs.existsSync(wikiInfoPath)) {
wikiInfo = $tw.utils.parseJSONSafe(fs.readFileSync(wikiInfoPath,"utf8"),function() {return null;});
}
if(wikiInfo) {
// Create the bag
const result = store.createBag(options.bagName,options.bagDescription);
if(result) {
console.log(`Error creating bag ${options.bagName} for edition ${options.wikiPath}: ${JSON.stringify(result)}`);
}
// Add plugins to the recipe list
const recipeList = [];
const processPlugins = function(type,plugins) {
$tw.utils.each(plugins,function(pluginName) {
const parts = pluginName.split("/");
let publisher, name;
if(parts.length === 2) {
publisher = parts[0];
name = parts[1];
} else {
name = parts[0];
}
recipeList.push(makePluginBagName(type,publisher,name));
});
};
processPlugins("plugin",wikiInfo.plugins);
processPlugins("theme",wikiInfo.themes);
processPlugins("language",wikiInfo.languages);
// Create the recipe
recipeList.push(options.bagName);
store.createRecipe(options.recipeName,recipeList,options.recipeDescription);
store.saveTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,options.wikiPath,$tw.config.wikiTiddlersSubDir),options.bagName);
}
}
copyEdition({
bagName: "docs",
bagDescription: "TiddlyWiki Documentation from https://tiddlywiki.com",
recipeName: "docs",
recipeDescription: "TiddlyWiki Documentation from https://tiddlywiki.com",
wikiPath: "tw5.com"
});
copyEdition({
bagName: "dev-docs",
bagDescription: "TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev",
recipeName: "dev-docs",
recipeDescription: "TiddlyWiki Developer Documentation from https://tiddlywiki.com/dev",
wikiPath: "dev"
});
copyEdition({
bagName: "tour",
bagDescription: "TiddlyWiki Interactive Tour from https://tiddlywiki.com",
recipeName: "tour",
recipeDescription: "TiddlyWiki Interactive Tour from https://tiddlywiki.com",
wikiPath: "tour"
});
// copyEdition({
// bagName: "full",
// bagDescription: "TiddlyWiki Fully Loaded Edition from https://tiddlywiki.com",
// recipeName: "full",
// recipeDescription: "TiddlyWiki Fully Loaded Edition from https://tiddlywiki.com",
// wikiPath: "full"
// });
// Create bags and recipes
store.createBag("bag-alpha","A test bag");
store.createBag("bag-beta","Another test bag");
store.createBag("bag-gamma","A further test bag");
store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"First wiki");
store.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Second Wiki");
store.createRecipe("recipe-tau",["bag-alpha"],"Third Wiki");
store.createRecipe("recipe-upsilon",["bag-alpha","bag-gamma","bag-beta"],"Fourth Wiki");
// Save tiddlers
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Alpha"},"bag-alpha");
store.saveBagTiddler({title: "😀😃😄😁😆🥹😅😂",text: "Bag Alpha"},"bag-alpha");
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Beta"},"bag-beta");
store.saveBagTiddler({title: "$:/SiteTitle",text: "Bag Gamma"},"bag-gamma");
console.timeEnd("mws-initial-load");
}
function ServerManager(store) {
this.servers = [];
}
ServerManager.prototype.createServer = function(options) {
const MWSServer = require("$:/plugins/tiddlywiki/multiwikiserver/mws-server.js").Server,
server = new MWSServer(options);
this.servers.push(server);
return server;
}
})();

View File

@ -0,0 +1,140 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/attachments.js
type: application/javascript
module-type: library
Class to handle the attachments in the filing system
The store folder looks like this:
store/
inbox/ - files that are in the process of being uploaded via a multipart form upload
202402282125432742/
0
1
...
...
files/ - files that are the text content of large tiddlers
b7def178-79c4-4d88-b7a4-39763014a58b/
data.jpg - the extension is provided for convenience when directly inspecting the file system
meta.json - contains:
{
"filename": "data.jpg",
"type": "video/mp4",
"uploaded": "2024021821224823"
}
database.sql - The database file (managed by sql-tiddler-database.js)
\*/
(function() {
/*
Class to handle an attachment store. Options include:
storePath - path to the store
*/
function AttachmentStore(options) {
options = options || {};
this.storePath = options.storePath;
}
/*
Check if an attachment name is valid
*/
AttachmentStore.prototype.isValidAttachmentName = function(attachment_name) {
const re = new RegExp('^[a-f0-9]{64}$');
return re.test(attachment_name);
};
/*
Saves an attachment to a file. Options include:
text: text content (may be binary)
type: MIME type of content
reference: reference to use for debugging
*/
AttachmentStore.prototype.saveAttachment = function(options) {
const path = require("path"),
fs = require("fs");
// Compute the content hash for naming the attachment
const contentHash = $tw.sjcl.codec.hex.fromBits($tw.sjcl.hash.sha256.hash(options.text)).slice(0,64).toString();
// Choose the best file extension for the attachment given its type
const contentTypeInfo = $tw.config.contentTypeInfo[options.type] || $tw.config.contentTypeInfo["application/octet-stream"];
// Creat the attachment directory
const attachmentPath = path.resolve(this.storePath,"files",contentHash);
$tw.utils.createDirectory(attachmentPath);
// Save the data file
const dataFilename = "data" + contentTypeInfo.extension;
fs.writeFileSync(path.resolve(attachmentPath,dataFilename),options.text,contentTypeInfo.encoding);
// Save the meta.json file
fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({
modified: $tw.utils.stringifyDate(new Date()),
contentHash: contentHash,
filename: dataFilename,
type: options.type
},null,4));
return contentHash;
};
/*
Adopts an attachment file into the store
*/
AttachmentStore.prototype.adoptAttachment = function(incomingFilepath,type,hash) {
const path = require("path"),
fs = require("fs");
// Choose the best file extension for the attachment given its type
const contentTypeInfo = $tw.config.contentTypeInfo[type] || $tw.config.contentTypeInfo["application/octet-stream"];
// Creat the attachment directory
const attachmentPath = path.resolve(this.storePath,"files",hash);
$tw.utils.createDirectory(attachmentPath);
// Rename the data file
const dataFilename = "data" + contentTypeInfo.extension,
dataFilepath = path.resolve(attachmentPath,dataFilename);
fs.renameSync(incomingFilepath,dataFilepath);
// Save the meta.json file
fs.writeFileSync(path.resolve(attachmentPath,"meta.json"),JSON.stringify({
modified: $tw.utils.stringifyDate(new Date()),
contentHash: hash,
filename: dataFilename,
type: type
},null,4));
return hash;
};
/*
Get an attachment ready to stream. Returns null if there is an error or:
stream: filestream of file
type: type of file
*/
AttachmentStore.prototype.getAttachmentStream = function(attachment_name) {
const path = require("path"),
fs = require("fs");
// Check the attachment name
if(this.isValidAttachmentName(attachment_name)) {
// Construct the path to the attachment directory
const attachmentPath = path.resolve(this.storePath,"files",attachment_name);
// Read the meta.json file
const metaJsonPath = path.resolve(attachmentPath,"meta.json");
if(fs.existsSync(metaJsonPath) && fs.statSync(metaJsonPath).isFile()) {
const meta = $tw.utils.parseJSONSafe(fs.readFileSync(metaJsonPath,"utf8"),function() {return null;});
if(meta) {
const dataFilepath = path.resolve(attachmentPath,meta.filename);
// Check if the data file exists
if(fs.existsSync(dataFilepath) && fs.statSync(dataFilepath).isFile()) {
// Stream the file
return {
stream: fs.createReadStream(dataFilepath),
type: meta.type
};
}
}
}
}
// An error occured
return null;
};
exports.AttachmentStore = AttachmentStore;
})();

View File

@ -0,0 +1,139 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js
type: application/javascript
module-type: library
Low level functions to work with the SQLite engine, either via better-sqlite3 or node-sqlite3-wasm.
This class is intended to encapsulate all engine-specific logic.
\*/
(function() {
/*
Create a database engine. Options include:
databasePath - path to the database file (can be ":memory:" or missing to get a temporary database)
engine - wasm | better
*/
function SqlEngine(options) {
options = options || {};
// Initialise transaction mechanism
this.transactionDepth = 0;
// Initialise the statement cache
this.statements = Object.create(null); // Hashmap by SQL text of statement objects
// Choose engine
this.engine = options.engine || "better"; // wasm | better
// Create the database file directories if needed
if(options.databasePath) {
$tw.utils.createFileDirectories(options.databasePath);
}
// Create the database
const databasePath = options.databasePath || ":memory:";
let Database;
switch(this.engine) {
case "wasm":
({ Database } = require("node-sqlite3-wasm"));
break;
case "better":
Database = require("better-sqlite3");
break;
}
this.db = new Database(databasePath,{
verbose: undefined && console.log
});
// Turn on WAL mode for better-sqlite3
if(this.engine === "better") {
// See https://github.com/WiseLibs/better-sqlite3/blob/master/docs/performance.md
this.db.pragma("journal_mode = WAL");
}
}
SqlEngine.prototype.close = function() {
for(const sql in this.statements) {
if(this.statements[sql].finalize) {
this.statements[sql].finalize();
}
}
this.statements = Object.create(null);
this.db.close();
this.db = undefined;
};
SqlEngine.prototype.normaliseParams = function(params) {
params = params || {};
const result = Object.create(null);
for(const paramName in params) {
if(this.engine !== "wasm" && paramName.startsWith("$")) {
result[paramName.slice(1)] = params[paramName];
} else {
result[paramName] = params[paramName];
}
}
return result;
};
SqlEngine.prototype.prepareStatement = function(sql) {
if(!(sql in this.statements)) {
this.statements[sql] = this.db.prepare(sql);
}
return this.statements[sql];
};
SqlEngine.prototype.runStatement = function(sql,params) {
params = this.normaliseParams(params);
const statement = this.prepareStatement(sql);
return statement.run(params);
};
SqlEngine.prototype.runStatementGet = function(sql,params) {
params = this.normaliseParams(params);
const statement = this.prepareStatement(sql);
return statement.get(params);
};
SqlEngine.prototype.runStatementGetAll = function(sql,params) {
params = this.normaliseParams(params);
const statement = this.prepareStatement(sql);
return statement.all(params);
};
SqlEngine.prototype.runStatements = function(sqlArray) {
for(const sql of sqlArray) {
this.runStatement(sql);
}
};
/*
Execute the given function in a transaction, committing if successful but rolling back if an error occurs. Returns whatever the given function returns.
Calls to this function can be safely nested, but only the topmost call will actually take place in a transaction.
TODO: better-sqlite3 provides its own transaction method which we should be using if available
*/
SqlEngine.prototype.transaction = function(fn) {
const alreadyInTransaction = this.transactionDepth > 0;
this.transactionDepth++;
try {
if(alreadyInTransaction) {
return fn();
} else {
this.runStatement(`BEGIN TRANSACTION`);
try {
var result = fn();
this.runStatement(`COMMIT TRANSACTION`);
} catch(e) {
this.runStatement(`ROLLBACK TRANSACTION`);
throw(e);
}
return result;
}
} finally {
this.transactionDepth--;
}
};
exports.SqlEngine = SqlEngine;
})();

View File

@ -0,0 +1,544 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js
type: application/javascript
module-type: library
Low level SQL functions to store and retrieve tiddlers in a SQLite database.
This class is intended to encapsulate all the SQL queries used to access the database.
Validation is for the most part left to the caller
\*/
(function() {
/*
Create a tiddler store. Options include:
databasePath - path to the database file (can be ":memory:" to get a temporary database)
engine - wasm | better
*/
function SqlTiddlerDatabase(options) {
options = options || {};
const SqlEngine = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-engine.js").SqlEngine;
this.engine = new SqlEngine({
databasePath: options.databasePath,
engine: options.engine
});
}
SqlTiddlerDatabase.prototype.close = function() {
this.engine.close();
};
SqlTiddlerDatabase.prototype.transaction = function(fn) {
return this.engine.transaction(fn);
};
SqlTiddlerDatabase.prototype.createTables = function() {
this.engine.runStatements([`
-- Bags have names and access control settings
CREATE TABLE IF NOT EXISTS bags (
bag_id INTEGER PRIMARY KEY AUTOINCREMENT,
bag_name TEXT UNIQUE NOT NULL,
accesscontrol TEXT NOT NULL,
description TEXT NOT NULL
)
`,`
-- Recipes have names...
CREATE TABLE IF NOT EXISTS recipes (
recipe_id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_name TEXT UNIQUE NOT NULL,
description TEXT NOT NULL
)
`,`
-- ...and recipes also have an ordered list of bags
CREATE TABLE IF NOT EXISTS recipe_bags (
recipe_id INTEGER NOT NULL,
bag_id INTEGER NOT NULL,
position INTEGER NOT NULL,
FOREIGN KEY (recipe_id) REFERENCES recipes(recipe_id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (recipe_id, bag_id)
)
`,`
-- Tiddlers are contained in bags and have titles
CREATE TABLE IF NOT EXISTS tiddlers (
tiddler_id INTEGER PRIMARY KEY AUTOINCREMENT,
bag_id INTEGER NOT NULL,
title TEXT NOT NULL,
is_deleted BOOLEAN NOT NULL,
attachment_blob TEXT, -- null or the name of an attachment blob
FOREIGN KEY (bag_id) REFERENCES bags(bag_id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (bag_id, title)
)
`,`
-- Tiddlers also have unordered lists of fields, each of which has a name and associated value
CREATE TABLE IF NOT EXISTS fields (
tiddler_id INTEGER,
field_name TEXT NOT NULL,
field_value TEXT NOT NULL,
FOREIGN KEY (tiddler_id) REFERENCES tiddlers(tiddler_id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (tiddler_id, field_name)
)
`]);
};
SqlTiddlerDatabase.prototype.listBags = function() {
const rows = this.engine.runStatementGetAll(`
SELECT bag_name, bag_id, accesscontrol, description
FROM bags
ORDER BY bag_name
`);
return rows;
};
/*
Create or update a bag
Returns the bag_id of the bag
*/
SqlTiddlerDatabase.prototype.createBag = function(bag_name,description,accesscontrol) {
accesscontrol = accesscontrol || "";
// Run the queries
this.engine.runStatement(`
INSERT OR IGNORE INTO bags (bag_name, accesscontrol, description)
VALUES ($bag_name, '', '')
`,{
$bag_name: bag_name
});
const updateBags = this.engine.runStatement(`
UPDATE bags
SET accesscontrol = $accesscontrol,
description = $description
WHERE bag_name = $bag_name
`,{
$bag_name: bag_name,
$accesscontrol: accesscontrol,
$description: description
});
return updateBags.lastInsertRowid;
};
/*
Returns array of {recipe_name:,recipe_id:,description:,bag_names: []}
*/
SqlTiddlerDatabase.prototype.listRecipes = function() {
const rows = this.engine.runStatementGetAll(`
SELECT r.recipe_name, r.recipe_id, r.description, b.bag_name, rb.position
FROM recipes AS r
JOIN recipe_bags AS rb ON rb.recipe_id = r.recipe_id
JOIN bags AS b ON rb.bag_id = b.bag_id
ORDER BY r.recipe_name, rb.position
`);
const results = [];
let currentRecipeName = null, currentRecipeIndex = -1;
for(const row of rows) {
if(row.recipe_name !== currentRecipeName) {
currentRecipeName = row.recipe_name;
currentRecipeIndex += 1;
results.push({
recipe_name: row.recipe_name,
recipe_id: row.recipe_id,
description: row.description,
bag_names: []
});
}
results[currentRecipeIndex].bag_names.push(row.bag_name);
}
return results;
};
/*
Create or update a recipe
Returns the recipe_id of the recipe
*/
SqlTiddlerDatabase.prototype.createRecipe = function(recipe_name,bag_names,description) {
// Run the queries
this.engine.runStatement(`
-- Delete existing recipe_bags entries for this recipe
DELETE FROM recipe_bags WHERE recipe_id = (SELECT recipe_id FROM recipes WHERE recipe_name = $recipe_name)
`,{
$recipe_name: recipe_name
});
const updateRecipes = this.engine.runStatement(`
-- Create the entry in the recipes table if required
INSERT OR REPLACE INTO recipes (recipe_name, description)
VALUES ($recipe_name, $description)
`,{
$recipe_name: recipe_name,
$description: description
});
this.engine.runStatement(`
INSERT INTO recipe_bags (recipe_id, bag_id, position)
SELECT r.recipe_id, b.bag_id, j.key as position
FROM recipes r
JOIN bags b
INNER JOIN json_each($bag_names) AS j ON j.value = b.bag_name
WHERE r.recipe_name = $recipe_name
`,{
$recipe_name: recipe_name,
$bag_names: JSON.stringify(bag_names)
});
return updateRecipes.lastInsertRowid;
};
/*
Returns {tiddler_id:}
*/
SqlTiddlerDatabase.prototype.saveBagTiddler = function(tiddlerFields,bag_name,attachment_blob) {
attachment_blob = attachment_blob || null;
// Update the tiddlers table
var info = this.engine.runStatement(`
INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob)
VALUES (
(SELECT bag_id FROM bags WHERE bag_name = $bag_name),
$title,
FALSE,
$attachment_blob
)
`,{
$title: tiddlerFields.title,
$attachment_blob: attachment_blob,
$bag_name: bag_name
});
// Update the fields table
this.engine.runStatement(`
INSERT OR REPLACE INTO fields (tiddler_id, field_name, field_value)
SELECT
t.tiddler_id,
json_each.key AS field_name,
json_each.value AS field_value
FROM (
SELECT tiddler_id
FROM tiddlers
WHERE bag_id = (
SELECT bag_id
FROM bags
WHERE bag_name = $bag_name
) AND title = $title
) AS t
JOIN json_each($field_values) AS json_each
`,{
$title: tiddlerFields.title,
$bag_name: bag_name,
$field_values: JSON.stringify(Object.assign({},tiddlerFields,{title: undefined}))
});
return {
tiddler_id: info.lastInsertRowid
}
};
/*
Returns {tiddler_id:,bag_name:} or null if the recipe is empty
*/
SqlTiddlerDatabase.prototype.saveRecipeTiddler = function(tiddlerFields,recipe_name,attachment_blob) {
// Find the topmost bag in the recipe
var row = this.engine.runStatementGet(`
SELECT b.bag_name
FROM bags AS b
JOIN (
SELECT rb.bag_id
FROM recipe_bags AS rb
WHERE rb.recipe_id = (
SELECT recipe_id
FROM recipes
WHERE recipe_name = $recipe_name
)
ORDER BY rb.position DESC
LIMIT 1
) AS selected_bag
ON b.bag_id = selected_bag.bag_id
`,{
$recipe_name: recipe_name
});
if(!row) {
return null;
}
// Save the tiddler to the topmost bag
var info = this.saveBagTiddler(tiddlerFields,row.bag_name,attachment_blob);
return {
tiddler_id: info.tiddler_id,
bag_name: row.bag_name
};
};
/*
Returns {tiddler_id:} of the delete marker
*/
SqlTiddlerDatabase.prototype.deleteTiddler = function(title,bag_name) {
// Delete the fields of this tiddler
this.engine.runStatement(`
DELETE FROM fields
WHERE tiddler_id IN (
SELECT t.tiddler_id
FROM tiddlers AS t
INNER JOIN bags AS b ON t.bag_id = b.bag_id
WHERE b.bag_name = $bag_name AND t.title = $title
)
`,{
$title: title,
$bag_name: bag_name
});
// Mark the tiddler itself as deleted
const rowDeleteMarker = this.engine.runStatement(`
INSERT OR REPLACE INTO tiddlers (bag_id, title, is_deleted, attachment_blob)
VALUES (
(SELECT bag_id FROM bags WHERE bag_name = $bag_name),
$title,
TRUE,
NULL
)
`,{
$title: title,
$bag_name: bag_name
});
return {tiddler_id: rowDeleteMarker.lastInsertRowid};
};
/*
returns {tiddler_id:,tiddler:,attachment_blob:}
*/
SqlTiddlerDatabase.prototype.getBagTiddler = function(title,bag_name) {
const rowTiddler = this.engine.runStatementGet(`
SELECT t.tiddler_id, t.attachment_blob
FROM bags AS b
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
WHERE t.title = $title AND b.bag_name = $bag_name AND t.is_deleted = FALSE
`,{
$title: title,
$bag_name: bag_name
});
if(!rowTiddler) {
return null;
}
const rows = this.engine.runStatementGetAll(`
SELECT field_name, field_value, tiddler_id
FROM fields
WHERE tiddler_id = $tiddler_id
`,{
$tiddler_id: rowTiddler.tiddler_id
});
if(rows.length === 0) {
return null;
} else {
return {
tiddler_id: rows[0].tiddler_id,
attachment_blob: rowTiddler.attachment_blob,
tiddler: rows.reduce((accumulator,value) => {
accumulator[value["field_name"]] = value.field_value;
return accumulator;
},{title: title})
};
}
};
/*
Returns {bag_name:, tiddler: {fields}, tiddler_id:, attachment_blob:}
*/
SqlTiddlerDatabase.prototype.getRecipeTiddler = function(title,recipe_name) {
const rowTiddlerId = this.engine.runStatementGet(`
SELECT t.tiddler_id, t.attachment_blob, b.bag_name
FROM bags AS b
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
WHERE r.recipe_name = $recipe_name
AND t.title = $title
AND t.is_deleted = FALSE
ORDER BY rb.position DESC
LIMIT 1
`,{
$title: title,
$recipe_name: recipe_name
});
if(!rowTiddlerId) {
return null;
}
// Get the fields
const rows = this.engine.runStatementGetAll(`
SELECT field_name, field_value
FROM fields
WHERE tiddler_id = $tiddler_id
`,{
$tiddler_id: rowTiddlerId.tiddler_id
});
return {
bag_name: rowTiddlerId.bag_name,
tiddler_id: rowTiddlerId.tiddler_id,
attachment_blob: rowTiddlerId.attachment_blob,
tiddler: rows.reduce((accumulator,value) => {
accumulator[value["field_name"]] = value.field_value;
return accumulator;
},{title: title})
};
};
/*
Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist
*/
SqlTiddlerDatabase.prototype.getBagTiddlers = function(bag_name) {
const rows = this.engine.runStatementGetAll(`
SELECT DISTINCT title, tiddler_id
FROM tiddlers
WHERE bag_id IN (
SELECT bag_id
FROM bags
WHERE bag_name = $bag_name
)
AND tiddlers.is_deleted = FALSE
ORDER BY title ASC
`,{
$bag_name: bag_name
});
return rows;
};
/*
Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist
*/
SqlTiddlerDatabase.prototype.getBagLastTiddlerId = function(bag_name) {
const row = this.engine.runStatementGet(`
SELECT tiddler_id
FROM tiddlers
WHERE bag_id IN (
SELECT bag_id
FROM bags
WHERE bag_name = $bag_name
)
ORDER BY tiddler_id DESC
LIMIT 1
`,{
$bag_name: bag_name
});
if(row) {
return row.tiddler_id;
} else {
return null;
}
};
/*
Get the metadata of the tiddlers in a recipe as an array [{title:,tiddler_id:,bag_name:,is_deleted:}],
sorted in ascending order of tiddler_id.
Options include:
limit: optional maximum number of results to return
last_known_tiddler_id: tiddler_id of the last known update. Only returns tiddlers that have been created, modified or deleted since
include_deleted: boolean, defaults to false
Returns null for recipes that do not exist
*/
SqlTiddlerDatabase.prototype.getRecipeTiddlers = function(recipe_name,options) {
options = options || {};
// Get the recipe ID
const rowsCheckRecipe = this.engine.runStatementGet(`
SELECT recipe_id FROM recipes WHERE recipes.recipe_name = $recipe_name
`,{
$recipe_name: recipe_name
});
if(!rowsCheckRecipe) {
return null;
}
const recipe_id = rowsCheckRecipe.recipe_id;
// Compose the query to get the tiddlers
const params = {
$recipe_id: recipe_id
}
if(options.limit) {
params.$limit = options.limit.toString();
}
if(options.last_known_tiddler_id) {
params.$last_known_tiddler_id = options.last_known_tiddler_id;
}
const rows = this.engine.runStatementGetAll(`
SELECT title, tiddler_id, is_deleted, bag_name
FROM (
SELECT t.title, t.tiddler_id, t.is_deleted, b.bag_name, MAX(rb.position) AS position
FROM bags AS b
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
WHERE rb.recipe_id = $recipe_id
${options.include_deleted ? "" : "AND t.is_deleted = FALSE"}
${options.last_known_tiddler_id ? "AND tiddler_id > $last_known_tiddler_id" : ""}
GROUP BY t.title
ORDER BY t.title, tiddler_id DESC
${options.limit ? "LIMIT $limit" : ""}
)
`,params);
return rows;
};
/*
Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist
*/
SqlTiddlerDatabase.prototype.getRecipeLastTiddlerId = function(recipe_name) {
const row = this.engine.runStatementGet(`
SELECT t.title, t.tiddler_id, b.bag_name, MAX(rb.position) AS position
FROM bags AS b
INNER JOIN recipe_bags AS rb ON b.bag_id = rb.bag_id
INNER JOIN recipes AS r ON rb.recipe_id = r.recipe_id
INNER JOIN tiddlers AS t ON b.bag_id = t.bag_id
WHERE r.recipe_name = $recipe_name
GROUP BY t.title
ORDER BY t.tiddler_id DESC
LIMIT 1
`,{
$recipe_name: recipe_name
});
if(row) {
return row.tiddler_id;
} else {
return null;
}
};
SqlTiddlerDatabase.prototype.deleteAllTiddlersInBag = function(bag_name) {
// Delete the fields
this.engine.runStatement(`
DELETE FROM fields
WHERE tiddler_id IN (
SELECT tiddler_id
FROM tiddlers
WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name)
AND is_deleted = FALSE
)
`,{
$bag_name: bag_name
});
// Mark the tiddlers as deleted
this.engine.runStatement(`
UPDATE tiddlers
SET is_deleted = TRUE
WHERE bag_id = (SELECT bag_id FROM bags WHERE bag_name = $bag_name)
AND is_deleted = FALSE
`,{
$bag_name: bag_name
});
};
/*
Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist
*/
SqlTiddlerDatabase.prototype.getRecipeBags = function(recipe_name) {
const rows = this.engine.runStatementGetAll(`
SELECT bags.bag_name
FROM bags
JOIN (
SELECT rb.bag_id, rb.position as position
FROM recipe_bags AS rb
JOIN recipes AS r ON rb.recipe_id = r.recipe_id
WHERE r.recipe_name = $recipe_name
ORDER BY rb.position
) AS bag_priority ON bags.bag_id = bag_priority.bag_id
ORDER BY position
`,{
$recipe_name: recipe_name
});
return rows.map(value => value.bag_name);
};
exports.SqlTiddlerDatabase = SqlTiddlerDatabase;
})();

View File

@ -0,0 +1,409 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js
type: application/javascript
module-type: library
Higher level functions to perform basic tiddler operations with a sqlite3 database.
This class is largely a wrapper for the sql-tiddler-database.js class, adding the following functionality:
* Validating requests (eg bag and recipe name constraints)
* Synchronising bag and recipe names to the admin wiki
* Handling large tiddlers as attachments
\*/
(function() {
/*
Create a tiddler store. Options include:
databasePath - path to the database file (can be ":memory:" to get a temporary database)
adminWiki - reference to $tw.Wiki object used for configuration
attachmentStore - reference to associated attachment store
engine - wasm | better
*/
function SqlTiddlerStore(options) {
options = options || {};
this.attachmentStore = options.attachmentStore;
this.adminWiki = options.adminWiki || $tw.wiki;
this.eventListeners = {}; // Hashmap by type of array of event listener functions
this.eventOutstanding = {}; // Hashmap by type of boolean true of outstanding events
// Create the database
this.databasePath = options.databasePath || ":memory:";
var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase;
this.sqlTiddlerDatabase = new SqlTiddlerDatabase({
databasePath: this.databasePath,
engine: options.engine
});
this.sqlTiddlerDatabase.createTables();
}
SqlTiddlerStore.prototype.addEventListener = function(type,listener) {
this.eventListeners[type] = this.eventListeners[type] || [];
this.eventListeners[type].push(listener);
};
SqlTiddlerStore.prototype.removeEventListener = function(type,listener) {
const listeners = this.eventListeners[type];
if(listeners) {
var p = listeners.indexOf(listener);
if(p !== -1) {
listeners.splice(p,1);
}
}
};
SqlTiddlerStore.prototype.dispatchEvent = function(type /*, args */) {
const self = this;
if(!this.eventOutstanding[type]) {
$tw.utils.nextTick(function() {
self.eventOutstanding[type] = false;
const args = Array.prototype.slice.call(arguments,1),
listeners = self.eventListeners[type];
if(listeners) {
for(var p=0; p<listeners.length; p++) {
var listener = listeners[p];
listener.apply(listener,args);
}
}
});
this.eventOutstanding[type] = true;
}
};
/*
Returns null if a bag/recipe name is valid, or a string error message if not
*/
SqlTiddlerStore.prototype.validateItemName = function(name,allowPrivilegedCharacters) {
if(typeof name !== "string") {
return "Not a valid string";
}
if(name.length > 256) {
return "Too long";
}
// Removed ~ from this list temporarily
if(allowPrivilegedCharacters) {
if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#%^&*()+={}\[\];\'\"<>,\\\?]+$/g.test(name))) {
return "Invalid character(s)";
}
} else {
if(!(/^[^\s\u00A0\x00-\x1F\x7F`!@#$%^&*()+={}\[\];:\'\"<>.,\/\\\?]+$/g.test(name))) {
return "Invalid character(s)";
}
}
return null;
};
/*
Returns null if the argument is an array of valid bag/recipe names, or a string error message if not
*/
SqlTiddlerStore.prototype.validateItemNames = function(names,allowPrivilegedCharacters) {
if(!$tw.utils.isArray(names)) {
return "Not a valid array";
}
var errors = [];
for(const name of names) {
const result = this.validateItemName(name,allowPrivilegedCharacters);
if(result && errors.indexOf(result) === -1) {
errors.push(result);
}
}
if(errors.length === 0) {
return null;
} else {
return errors.join("\n");
}
};
SqlTiddlerStore.prototype.close = function() {
this.sqlTiddlerDatabase.close();
this.sqlTiddlerDatabase = undefined;
};
/*
Given tiddler fields, tiddler_id and a bag_name, return the tiddler fields after the following process:
- Apply the tiddler_id as the revision field
- Apply the bag_name as the bag field
*/
SqlTiddlerStore.prototype.processOutgoingTiddler = function(tiddlerFields,tiddler_id,bag_name,attachment_blob) {
if(attachment_blob !== null) {
return $tw.utils.extend(
{},
tiddlerFields,
{
text: undefined,
_canonical_uri: `/bags/${encodeURIComponent(bag_name)}/tiddlers/${encodeURIComponent(tiddlerFields.title)}/blob`
}
);
} else {
return tiddlerFields;
}
};
/*
*/
SqlTiddlerStore.prototype.processIncomingTiddler = function(tiddlerFields) {
let attachmentSizeLimit = $tw.utils.parseNumber(this.adminWiki.getTiddlerText("$:/config/MultiWikiServer/AttachmentSizeLimit"));
if(attachmentSizeLimit < 100 * 1024) {
attachmentSizeLimit = 100 * 1024;
}
const contentTypeInfo = $tw.config.contentTypeInfo[tiddlerFields.type || "text/vnd.tiddlywiki"],
isBinary = !!contentTypeInfo && contentTypeInfo.encoding === "base64";
if(isBinary && tiddlerFields.text && tiddlerFields.text.length > attachmentSizeLimit) {
const attachment_blob = this.attachmentStore.saveAttachment({
text: tiddlerFields.text,
type: tiddlerFields.type,
reference: tiddlerFields.title
});
return {
tiddlerFields: Object.assign({},tiddlerFields,{text: undefined}),
attachment_blob: attachment_blob
};
} else {
return {
tiddlerFields: tiddlerFields,
attachment_blob: null
};
}
};
SqlTiddlerStore.prototype.saveTiddlersFromPath = function(tiddler_files_path,bag_name) {
var self = this;
this.sqlTiddlerDatabase.transaction(function() {
// Clear out the bag
self.deleteAllTiddlersInBag(bag_name);
// Get the tiddlers
var path = require("path");
var tiddlersFromPath = $tw.loadTiddlersFromPath(path.resolve($tw.boot.corePath,$tw.config.editionsPath,tiddler_files_path));
// Save the tiddlers
for(const tiddlersFromFile of tiddlersFromPath) {
for(const tiddler of tiddlersFromFile.tiddlers) {
self.saveBagTiddler(tiddler,bag_name,null);
}
}
});
self.dispatchEvent("change");
};
SqlTiddlerStore.prototype.listBags = function() {
return this.sqlTiddlerDatabase.listBags();
};
/*
Options include:
allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name
*/
SqlTiddlerStore.prototype.createBag = function(bag_name,description,options) {
options = options || {};
var self = this;
return this.sqlTiddlerDatabase.transaction(function() {
const validationBagName = self.validateItemName(bag_name,options.allowPrivilegedCharacters);
if(validationBagName) {
return {message: validationBagName};
}
self.sqlTiddlerDatabase.createBag(bag_name,description);
self.dispatchEvent("change");
return null;
});
};
SqlTiddlerStore.prototype.listRecipes = function() {
return this.sqlTiddlerDatabase.listRecipes();
};
/*
Returns null on success, or {message:} on error
Options include:
allowPrivilegedCharacters - allows "$", ":" and "/" to appear in recipe name
*/
SqlTiddlerStore.prototype.createRecipe = function(recipe_name,bag_names,description,options) {
bag_names = bag_names || [];
description = description || "";
options = options || {};
const validationRecipeName = this.validateItemName(recipe_name,options.allowPrivilegedCharacters);
if(validationRecipeName) {
return {message: validationRecipeName};
}
if(bag_names.length === 0) {
return {message: "Recipes must contain at least one bag"};
}
var self = this;
return this.sqlTiddlerDatabase.transaction(function() {
self.sqlTiddlerDatabase.createRecipe(recipe_name,bag_names,description);
self.dispatchEvent("change");
return null;
});
};
/*
Returns {tiddler_id:}
*/
SqlTiddlerStore.prototype.saveBagTiddler = function(incomingTiddlerFields,bag_name) {
const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields);
const result = this.sqlTiddlerDatabase.saveBagTiddler(tiddlerFields,bag_name,attachment_blob);
this.dispatchEvent("change");
return result;
};
/*
Create a tiddler in a bag adopting the specified file as the attachment. The attachment file must be on the same disk as the attachment store
Options include:
filepath - filepath to the attachment file
hash - string hash of the attachment file
type - content type of file as uploaded
Returns {tiddler_id:}
*/
SqlTiddlerStore.prototype.saveBagTiddlerWithAttachment = function(incomingTiddlerFields,bag_name,options) {
const attachment_blob = this.attachmentStore.adoptAttachment(options.filepath,options.type,options.hash);
if(attachment_blob) {
const result = this.sqlTiddlerDatabase.saveBagTiddler(incomingTiddlerFields,bag_name,attachment_blob);
this.dispatchEvent("change");
return result;
} else {
return null;
}
};
/*
Returns {tiddler_id:,bag_name:}
*/
SqlTiddlerStore.prototype.saveRecipeTiddler = function(incomingTiddlerFields,recipe_name) {
const {tiddlerFields, attachment_blob} = this.processIncomingTiddler(incomingTiddlerFields);
const result = this.sqlTiddlerDatabase.saveRecipeTiddler(tiddlerFields,recipe_name,attachment_blob);
this.dispatchEvent("change");
return result;
};
SqlTiddlerStore.prototype.deleteTiddler = function(title,bag_name) {
const result = this.sqlTiddlerDatabase.deleteTiddler(title,bag_name);
this.dispatchEvent("change");
return result;
};
/*
returns {tiddler_id:,tiddler:}
*/
SqlTiddlerStore.prototype.getBagTiddler = function(title,bag_name) {
var tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name);
if(tiddlerInfo) {
return Object.assign(
{},
tiddlerInfo,
{
tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,bag_name,tiddlerInfo.attachment_blob)
});
} else {
return null;
}
};
/*
Get an attachment ready to stream. Returns null if there is an error or:
tiddler_id: revision of tiddler
stream: stream of file
type: type of file
Returns {tiddler_id:,bag_name:}
*/
SqlTiddlerStore.prototype.getBagTiddlerStream = function(title,bag_name) {
const tiddlerInfo = this.sqlTiddlerDatabase.getBagTiddler(title,bag_name);
if(tiddlerInfo) {
if(tiddlerInfo.attachment_blob) {
return $tw.utils.extend(
{},
this.attachmentStore.getAttachmentStream(tiddlerInfo.attachment_blob),
{
tiddler_id: tiddlerInfo.tiddler_id
}
);
} else {
const { Readable } = require('stream');
const stream = new Readable();
stream._read = function() {
// Push data
const type = tiddlerInfo.tiddler.type || "text/plain";
stream.push(tiddlerInfo.tiddler.text || "",($tw.config.contentTypeInfo[type] ||{encoding: "utf8"}).encoding);
// Push null to indicate the end of the stream
stream.push(null);
};
return {
tiddler_id: tiddlerInfo.tiddler_id,
bag_name: bag_name,
stream: stream,
type: tiddlerInfo.tiddler.type || "text/plain"
}
}
} else {
return null;
}
};
/*
Returns {bag_name:, tiddler: {fields}, tiddler_id:}
*/
SqlTiddlerStore.prototype.getRecipeTiddler = function(title,recipe_name) {
var tiddlerInfo = this.sqlTiddlerDatabase.getRecipeTiddler(title,recipe_name);
if(tiddlerInfo) {
return Object.assign(
{},
tiddlerInfo,
{
tiddler: this.processOutgoingTiddler(tiddlerInfo.tiddler,tiddlerInfo.tiddler_id,tiddlerInfo.bag_name,tiddlerInfo.attachment_blob)
});
} else {
return null;
}
};
/*
Get the titles of the tiddlers in a bag. Returns an empty array for bags that do not exist
*/
SqlTiddlerStore.prototype.getBagTiddlers = function(bag_name) {
return this.sqlTiddlerDatabase.getBagTiddlers(bag_name);
};
/*
Get the tiddler_id of the newest tiddler in a bag. Returns null for bags that do not exist
*/
SqlTiddlerStore.prototype.getBagLastTiddlerId = function(bag_name) {
return this.sqlTiddlerDatabase.getBagLastTiddlerId(bag_name);
};
/*
Get the titles of the tiddlers in a recipe as {title:,bag_name:}. Returns null for recipes that do not exist
*/
SqlTiddlerStore.prototype.getRecipeTiddlers = function(recipe_name,options) {
return this.sqlTiddlerDatabase.getRecipeTiddlers(recipe_name,options);
};
/*
Get the tiddler_id of the newest tiddler in a recipe. Returns null for recipes that do not exist
*/
SqlTiddlerStore.prototype.getRecipeLastTiddlerId = function(recipe_name) {
return this.sqlTiddlerDatabase.getRecipeLastTiddlerId(recipe_name);
};
SqlTiddlerStore.prototype.deleteAllTiddlersInBag = function(bag_name) {
var self = this;
return this.sqlTiddlerDatabase.transaction(function() {
const result = self.sqlTiddlerDatabase.deleteAllTiddlersInBag(bag_name);
self.dispatchEvent("change");
return result;
});
};
/*
Get the names of the bags in a recipe. Returns an empty array for recipes that do not exist
*/
SqlTiddlerStore.prototype.getRecipeBags = function(recipe_name) {
return this.sqlTiddlerDatabase.getRecipeBags(recipe_name);
};
exports.SqlTiddlerStore = SqlTiddlerStore;
})();

View File

@ -0,0 +1,112 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-database.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the SQL tiddler database layer
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
if($tw.node) {
describe("SQL tiddler database with node-sqlite3-wasm", function() {
runSqlDatabaseTests("wasm");
});
describe("SQL tiddler database with better-sqlite3", function() {
runSqlDatabaseTests("better");
});
function runSqlDatabaseTests(engine) {
// Create and initialise the tiddler store
var SqlTiddlerDatabase = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-database.js").SqlTiddlerDatabase;
const sqlTiddlerDatabase = new SqlTiddlerDatabase({
engine: engine
});
sqlTiddlerDatabase.createTables();
// Tear down
afterAll(function() {
// Close the database
sqlTiddlerDatabase.close();
});
// Run tests
it("should save and retrieve tiddlers using engine: " + engine, function() {
// Create bags and recipes
expect(sqlTiddlerDatabase.createBag("bag-alpha","Bag alpha")).toEqual(1);
expect(sqlTiddlerDatabase.createBag("bag-beta","Bag beta")).toEqual(2);
expect(sqlTiddlerDatabase.createBag("bag-gamma","Bag gamma")).toEqual(3);
expect(sqlTiddlerDatabase.listBags()).toEqual([
{ bag_name: 'bag-alpha', bag_id: 1, accesscontrol: '', description: "Bag alpha" },
{ bag_name: 'bag-beta', bag_id: 2, accesscontrol: '', description: "Bag beta" },
{ bag_name: 'bag-gamma', bag_id: 3, accesscontrol: '', description: "Bag gamma" }
]);
expect(sqlTiddlerDatabase.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(1);
expect(sqlTiddlerDatabase.createRecipe("recipe-sigma",["bag-alpha","bag-gamma"],"Recipe sigma")).toEqual(2);
expect(sqlTiddlerDatabase.createRecipe("recipe-tau",["bag-alpha"],"Recipe tau")).toEqual(3);
expect(sqlTiddlerDatabase.createRecipe("recipe-upsilon",["bag-alpha","bag-gamma","bag-beta"],"Recipe upsilon")).toEqual(4);
expect(sqlTiddlerDatabase.listRecipes()).toEqual([
{ recipe_name: 'recipe-rho', recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho" },
{ recipe_name: 'recipe-sigma', recipe_id: 2, bag_names: ["bag-alpha","bag-gamma"], description: "Recipe sigma" },
{ recipe_name: 'recipe-tau', recipe_id: 3, bag_names: ["bag-alpha"], description: "Recipe tau" },
{ recipe_name: 'recipe-upsilon', recipe_id: 4, bag_names: ["bag-alpha","bag-gamma","bag-beta"], description: "Recipe upsilon" }
]);
expect(sqlTiddlerDatabase.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]);
expect(sqlTiddlerDatabase.getRecipeBags("recipe-sigma")).toEqual(["bag-alpha","bag-gamma"]);
expect(sqlTiddlerDatabase.getRecipeBags("recipe-tau")).toEqual(["bag-alpha"]);
expect(sqlTiddlerDatabase.getRecipeBags("recipe-upsilon")).toEqual(["bag-alpha","bag-gamma","bag-beta"]);
// Save tiddlers
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Another Tiddler",text: "I'm in alpha",tags: "one two three"},"bag-alpha")).toEqual({
tiddler_id: 1
});
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in alpha as well",tags: "one two three"},"bag-alpha")).toEqual({
tiddler_id: 2
});
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in beta",tags: "four five six"},"bag-beta")).toEqual({
tiddler_id: 3
});
expect(sqlTiddlerDatabase.saveBagTiddler({title: "Hello There",text: "I'm in gamma",tags: "seven eight nine"},"bag-gamma")).toEqual({
tiddler_id: 4
});
// Verify what we've got
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
{ title: 'Hello There', tiddler_id: 3, bag_name: 'bag-beta', is_deleted: 0 }
]);
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
{ title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 }
]);
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-rho").tiddler).toEqual({ title: "Hello There", text: "I'm in beta", tags: "four five six" });
expect(sqlTiddlerDatabase.getRecipeTiddler("Missing Tiddler","recipe-rho")).toEqual(null);
expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-rho").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" });
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-sigma").tiddler).toEqual({ title: "Hello There", text: "I'm in gamma", tags: "seven eight nine" });
expect(sqlTiddlerDatabase.getRecipeTiddler("Another Tiddler","recipe-sigma").tiddler).toEqual({ title: "Another Tiddler", text: "I'm in alpha", tags: "one two three" });
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-upsilon").tiddler).toEqual({title: "Hello There",text: "I'm in beta",tags: "four five six"});
// Delete a tiddlers to ensure the underlying tiddler in the recipe shows through
sqlTiddlerDatabase.deleteTiddler("Hello There","bag-beta");
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
{ title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 }
]);
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([
{ title: 'Another Tiddler', tiddler_id: 1, bag_name: 'bag-alpha', is_deleted: 0 },
{ title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 }
]);
expect(sqlTiddlerDatabase.getRecipeTiddler("Hello There","recipe-beta")).toEqual(null);
sqlTiddlerDatabase.deleteTiddler("Another Tiddler","bag-alpha");
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-rho")).toEqual([ { title: 'Hello There', tiddler_id: 2, bag_name: 'bag-alpha', is_deleted: 0 } ]);
expect(sqlTiddlerDatabase.getRecipeTiddlers("recipe-sigma")).toEqual([ { title: 'Hello There', tiddler_id: 4, bag_name: 'bag-gamma', is_deleted: 0 } ]);
// Save a recipe tiddler
expect(sqlTiddlerDatabase.saveRecipeTiddler({title: "More", text: "None"},"recipe-rho")).toEqual({tiddler_id: 7, bag_name: 'bag-beta'});
expect(sqlTiddlerDatabase.getRecipeTiddler("More","recipe-rho").tiddler).toEqual({title: "More", text: "None"});
});
}
}
})();

View File

@ -0,0 +1,150 @@
/*\
title: $:/plugins/tiddlywiki/multiwikiserver/store/tests-sql-tiddler-store.js
type: application/javascript
tags: [[$:/tags/test-spec]]
Tests the SQL tiddler store layer
\*/
(function(){
/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";
if($tw.node) {
describe("SQL tiddler store with node-sqlite3-wasm", function() {
runSqlStoreTests("wasm");
});
describe("SQL tiddler store with better-sqlite3", function() {
runSqlStoreTests("better");
});
function runSqlStoreTests(engine) {
var SqlTiddlerStore = require("$:/plugins/tiddlywiki/multiwikiserver/store/sql-tiddler-store.js").SqlTiddlerStore;
var store;
beforeEach(function() {
store = new SqlTiddlerStore({
databasePath: ":memory:",
engine: engine
});
});
afterEach(function() {
store.close();
store = null;
});
it("should return empty results without failure on an empty store", function() {
expect(store.listBags()).toEqual([]);
expect(store.listRecipes()).toEqual([]);
});
it("should return a single bag after creating a bag", function() {
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
expect(store.listBags()).toEqual([{
bag_name: "bag-alpha",
bag_id: 1,
accesscontrol: "",
description: "Bag alpha"
}]);
});
it("should return empty results after failing to create a bag with an invalid name", function() {
expect(store.createBag("bag alpha", "Bag alpha")).toEqual({
message: "Invalid character(s)"
});
expect(store.listBags()).toEqual([]);
});
it("should return a bag with new description after re-creating", function() {
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
expect(store.createBag("bag-alpha", "Different description")).toEqual(null);
expect(store.listBags()).toEqual([{
bag_name: "bag-alpha",
bag_id: 1,
accesscontrol: "",
description: "Different description"
}]);
});
it("should return a saved tiddler within a bag", function() {
expect(store.createBag("bag-alpha", "Bag alpha")).toEqual(null);
var saveBagResult = store.saveBagTiddler({
title: "Another Tiddler",
text: "I'm in alpha",
tags: "one two three"
}, "bag-alpha");
expect(new Set(Object.keys(saveBagResult))).toEqual(new Set(["tiddler_id"]));
expect(typeof(saveBagResult.tiddler_id)).toBe("number");
expect(store.getBagTiddlers("bag-alpha")).toEqual([{title: "Another Tiddler", tiddler_id: 1}]);
var getBagTiddlerResult = store.getBagTiddler("Another Tiddler","bag-alpha");
expect(typeof(getBagTiddlerResult.tiddler_id)).toBe("number");
delete getBagTiddlerResult.tiddler_id;
expect(getBagTiddlerResult).toEqual({ attachment_blob: null, tiddler: {title: "Another Tiddler", text: "I'm in alpha", tags: "one two three"} });
});
it("should return a single recipe after creating that recipe", function() {
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
expect(store.listRecipes()).toEqual([
{ recipe_name: "recipe-rho", recipe_id: 1, bag_names: ["bag-alpha","bag-beta"], description: "Recipe rho" }
]);
});
it("should return a recipe's bags after creating that recipe", function() {
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
expect(store.getRecipeBags("recipe-rho")).toEqual(["bag-alpha","bag-beta"]);
});
it("should return a saved tiddler within a recipe", function() {
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
expect(store.createBag("bag-beta","Bag beta")).toEqual(null);
expect(store.createRecipe("recipe-rho",["bag-alpha","bag-beta"],"Recipe rho")).toEqual(null);
var saveRecipeResult = store.saveRecipeTiddler({
title: "Another Tiddler",
text: "I'm in rho"
},"recipe-rho");
expect(new Set(Object.keys(saveRecipeResult))).toEqual(new Set(["tiddler_id", "bag_name"]));
expect(typeof(saveRecipeResult.tiddler_id)).toBe("number");
expect(saveRecipeResult.bag_name).toBe("bag-beta");
expect(store.getRecipeTiddlers("recipe-rho")).toEqual([{title: "Another Tiddler", tiddler_id: 1, bag_name: "bag-beta", is_deleted: 0 }]);
var getRecipeTiddlerResult = store.getRecipeTiddler("Another Tiddler","recipe-rho");
expect(typeof(getRecipeTiddlerResult.tiddler_id)).toBe("number");
delete getRecipeTiddlerResult.tiddler_id;
expect(getRecipeTiddlerResult).toEqual({ attachment_blob: null, bag_name: "bag-beta", tiddler: {title: "Another Tiddler", text: "I'm in rho"} });
});
it("should return no tiddlers after the only one has been deleted", function() {
expect(store.createBag("bag-alpha","Bag alpha")).toEqual(null);
store.saveBagTiddler({
title: "Another Tiddler",
text: "I'm in alpha",
tags: "one two three"
}, "bag-alpha");
store.deleteTiddler("Another Tiddler","bag-alpha");
expect(store.getBagTiddlers("bag-alpha")).toEqual([]);
});
}
}
})();

View File

@ -0,0 +1,7 @@
{
"title": "$:/plugins/tiddlywiki/multiwikiserver",
"name": "Multi Wiki Server",
"description": "Multiple Wiki Server Extension",
"list": "readme docs",
"dependents": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/missing-favicon.png
tags: $:/tags/MWS/SystemFile
type: image/png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/motovun-jack.jpg
tags: $:/tags/MWS/SystemFile
type: image/jpeg

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -0,0 +1,3 @@
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/mws-icon.png
tags: $:/tags/MWS/SystemFile
type: image/png

View File

@ -0,0 +1,113 @@
title: $:/plugins/tiddlywiki/multiwikiserver/system-files/styles.css
tags: $:/tags/MWS/SystemFileWikified
system-file-type: text/css
\import [subfilter{$:/core/config/GlobalImportFilter}]
\rules only filteredtranscludeinline transcludeinline macrodef macrocallinline macrocallblock
/* Import TiddlyWiki theme styles */
{{$:/core/ui/PageStylesheet}}
/* MWS Styles */
body {
padding: 1rem;
}
.mws-wiki-card {
display: flex;
margin: 1em 0;
width: 100%;
text-decoration: none;
color: <<colour foreground>>;
background: <<colour background>>;
border-radius: 0.28571429rem;
box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5;
padding: 0.5em 0.5em 0.5em 1em;
}
.mws-wiki-card:hover {
background: <<colour tiddler-info-background>>;
color: <<colour foreground>>;
}
.mws-wiki-card-image {
display: flex;
align-items: center;
}
.mws-wiki-card-content {
padding-left: 1em;
}
.mws-wiki-card-header {
font-size: 1.3em;
font-weight: bold;
margin: 0 0 0.25em 0;
}
.mws-wiki-card-meta {
color: <<colour muted-foreground>>;
}
.mws-wiki-card-description {
}
.mws-vertical-list {
list-style: none;
padding: 0;
line-height: 1.5;
}
.mws-horizontal-list {
list-style: none;
padding: 0;
}
.mws-horizontal-list > li {
display: inline-block;
}
.mws-bag-pill {
background: <<colour muted-foreground>>;
color: <<colour background>>;
fill: <<colour background>>;
margin-right: 0.5em;
border-radius: 0.25em;
padding: 0 0.25em;
}
.mws-bag-pill:hover {
background: <<colour foreground>>;
color: <<colour background>>;
fill: <<colour background>>;
}
.mws-bag-pill-topmost {
background: <<colour very-muted-foreground>>;
}
.mws-bag-pill .mws-bag-pill-label {
margin-left: 0.5em;
}
.mws-bag-pill-link {
text-decoration: none;
color: currentcolor;
}
.mws-favicon {
object-fit: contain;
width: 4em;
max-height: 4em;
}
.mws-favicon-small {
object-fit: contain;
vertical-align: text-bottom;
width: 1em;
max-height: 1em;
}

View File

@ -0,0 +1,45 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-bag
! <img
src=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
class="mws-favicon-small"
width="32px"
/> Bag <$text text={{{ [<bag-name>]}}}/>
<form
method="post"
action="tiddlers/"
enctype="multipart/form-data"
>
<div>
<label>
File to upload:
</label>
<input type="file" name="file-to-upload" accept="*/*" />
</div>
<div>
<label>
Tiddler title:
</label>
<input type="text" name="tiddler-field-title" />
</div>
<div>
<label>
Tiddler tags:
</label>
<input type="text" name="tiddler-field-tags" />
</div>
<div>
<input type="submit" value="Upload"/>
</div>
</form>
<ul>
<$list filter="[<bag-titles>jsonget[]sort[]]">
<li>
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$text text=<<currentTiddler>>/>
</a>
</li>
</$list>
</ul>

View File

@ -0,0 +1,152 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/get-index
\function .hide.system()
[<show-system>match[on]]
[all[]!prefix[$:/]]
\end
\procedure bagPill(element-tag:"span",is-topmost:"yes")
\whitespace trim
<$genesis $type=<<element-tag>> class={{{ mws-bag-pill [<is-topmost>match[yes]then[mws-bag-pill-topmost]] +[join[ ]] }}}>
<a class="mws-bag-pill-link" href=`/bags/${ [<bag-name>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<img
src=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
class="mws-favicon-small"
/>
<span class="mws-bag-pill-label">
<$text text=<<bag-name>>/>
</span>
</a>
</$genesis>
\end
! Wikis Available Here
<ul class="mws-vertical-list">
<$list filter="[<recipe-list>jsonindexes[]] :sort[<currentTiddler>jsonget[recipe_name]]" variable="recipe-index">
<li>
<$let
recipe-info={{{ [<recipe-list>jsonextract<recipe-index>] }}}
recipe-name={{{ [<recipe-info>jsonget[recipe_name]] }}}
>
<div
class="mws-wiki-card"
>
<div class="mws-wiki-card-image">
<img
src=`/recipes/${ [<recipe-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico?fallback=/.system/missing-favicon.png`
class="mws-favicon"
/>
</div>
<div class="mws-wiki-card-content">
<div class="mws-wiki-card-header">
<a
href=`/wiki/${ [<recipe-name>encodeuricomponent[]] }$`
rel="noopener noreferrer"
target="_blank"
>
<$text text={{{ [<recipe-info>jsonget[recipe_name]] }}}/>
</a>
</div>
<div class="mws-wiki-card-meta">
<%if true %>
<ol class="mws-vertical-list">
<$list filter="[<recipe-info>jsonget[bag_names]reverse[]] :filter[.hide.system[]]" variable="bag-name" counter="counter">
<$transclude $variable="bagPill" is-topmost={{{ [<counter-first>match[yes]] }}} element-tag="li"/>
</$list>
</ol>
<%else%>
(no bags defined)
<%endif%>
</div>
<div class="mws-wiki-card-description">
<$text text={{{ [<recipe-info>jsonget[description]] }}}/>
</div>
</div>
</div>
</$let>
</li>
</$list>
</ul>
<form action="/recipes" method="post" class="mws-form">
<div class="mws-form-heading">
Create a new recipe or modify and existing one
</div>
<div class="mws-form-fields">
<div class="mws-form-field">
<label class="mws-form-field-description">
Recipe name
</label>
<input name="recipe_name" type="text"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Recipe description
</label>
<input name="description" type="text"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Bags in recipe (space separated)
</label>
<input name="bag_names" type="text"/>
</div>
</div>
<div class="mws-form-buttons">
<input type="submit" value="Create or Update Recipe" formmethod="post"/>
</div>
</form>
! Bags
<ul class="mws-vertical-list">
<$list filter="[<bag-list>jsonindexes[]] :filter[<bag-list>jsonget<currentTiddler>,[bag_name].hide.system[]] :sort[<bag-list>jsonget<currentTiddler>,[bag_name]]" variable="bag-index" counter="counter">
<li class="mws-wiki-card">
<$let
bag-info={{{ [<bag-list>jsonextract<bag-index>] }}}
bag-name={{{ [<bag-info>jsonget[bag_name]] }}}
>
<$transclude $variable="bagPill"/>
<$text text={{{ [<bag-info>jsonget[description]] }}}/>
</$let>
</li>
</$list>
</ul>
<form action="/bags" method="post" class="mws-form">
<div class="mws-form-heading">
Create a new bag or modify and existing one
</div>
<div class="mws-form-fields">
<div class="mws-form-field">
<label class="mws-form-field-description">
Bag name
</label>
<input name="bag_name" type="text"/>
</div>
<div class="mws-form-field">
<label class="mws-form-field-description">
Bag description
</label>
<input name="description" type="text"/>
</div>
</div>
<div class="mws-form-buttons">
<input type="submit" value="Create or Update Bag" formmethod="post"/>
</div>
</form>
! Advanced
<form id="checkboxForm" action="." method="GET">
<%if [<show-system>match[on]] %>
<input type="checkbox" id="chkShowSystem" name="show_system" value="on" checked="checked"/>
<%else%>
<input type="checkbox" id="chkShowSystem" name="show_system" value="on"/>
<%endif%>
<label for="chkShowSystem">Show system bags</label>
<button type="submit">Update</button>
</form>

View File

@ -0,0 +1,20 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/page
<!--
Template for the basic HTML page layout. Expects the following variables:
page-content: title of tiddler containing the main page content
-->
`
<!doctype html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<link rel="stylesheet" href="/.system/styles.css">
</head>
<body class="tc-body">
`
<$view tiddler=<<page-content>> field="text" format="htmlwikified" />
`
</body>
</html>
`

View File

@ -0,0 +1,31 @@
title: $:/plugins/tiddlywiki/multiwikiserver/templates/post-bag-tiddlers
! <$image
source=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/%24%3A%2Ffavicon.ico`
class="mws-favicon-small"
width="32px"
>
<$image
source="$:/plugins/multiwikiserver/images/missing-favicon.png"
class="mws-favicon-small"
width="32px"
/>
</$image> Bag <$text text={{{ [<bag-name>]}}}/>
<p>
Go back to <a href="..">Bag <$text text={{{ [<bag-name>]}}}/></a>
</p>
<p>
The following tiddlers were successfully imported:
</p>
<ul>
<$list filter="[<imported-titles>jsonget[]sort[]]">
<li>
<a href=`/bags/${ [<bag-name>encodeuricomponent[]] }$/tiddlers/${ [<currentTiddler>encodeuricomponent[]] }$` rel="noopener noreferrer" target="_blank">
<$text text=<<currentTiddler>>/>
</a>
</li>
</$list>
</ul>

View File

@ -42,7 +42,8 @@ TiddlyWebAdaptor.prototype.getHost = function() {
var text = this.wiki.getTiddlerText(CONFIG_HOST_TIDDLER,DEFAULT_HOST_TIDDLER),
substitutions = [
{name: "protocol", value: document.location.protocol},
{name: "host", value: document.location.host}
{name: "host", value: document.location.host},
{name: "pathname", value: document.location.pathname}
];
for(var t=0; t<substitutions.length; t++) {
var s = substitutions[t];

View File

@ -1,7 +1,11 @@
<p>Welcome to <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a>, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> is a complete interactive wiki in <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/JavaScript.html">JavaScript</a>. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/WikiText.html">WikiText</a>.</p><p>Learn more and see it in action at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/</a></p><p>Developer documentation is in progress at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/dev/</a></p><h1 class="">Join the Community</h1><p>
<h2 class="">Official Forums</h2><p>The new official forum for talking about TiddlyWiki: requests for help, announcements of new releases and plugins, debating new features, or just sharing experiences. You can participate via the associated website, or subscribe via email.</p><p><a class="tc-tiddlylink-external" href="https://talk.tiddlywiki.org/" rel="noopener noreferrer" target="_blank">https://talk.tiddlywiki.org/</a></p><p>Note that talk.tiddlywiki.org is a community run service that we host and maintain ourselves. The modest running costs are covered by community contributions.</p><p>For the convenience of existing users, we also continue to operate the original TiddlyWiki group (hosted on Google Groups since 2005):</p><p><a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWiki" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWiki</a></p><h2 class="">Developer Forums</h2><p>There are several resources for developers to learn more about <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> and to discuss and contribute to its development.</p><ul><li><a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev" rel="noopener noreferrer" target="_blank">tiddlywiki.com/dev</a> is the official developer documentation</li><li>Get involved in the <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5" rel="noopener noreferrer" target="_blank">development on GitHub</a><ul><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">Discussions</a> are for Q&amp;A and open-ended discussion</li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/issues" rel="noopener noreferrer" target="_blank">Issues</a> are for raising bug reports and proposing specific, actionable new ideas</li></ul></li><li>The older TiddlyWikiDev Google Group is now closed in favour of <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">GitHub Discussions</a> but remains a useful archive: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWikiDev" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWikiDev</a><ul><li>An enhanced group search facility is available on <a class="tc-tiddlylink-external" href="https://www.mail-archive.com/tiddlywikidev@googlegroups.com/" rel="noopener noreferrer" target="_blank">mail-archive.com</a></li></ul></li><li>Follow <a class="tc-tiddlylink-external" href="http://twitter.com/#!/TiddlyWiki" rel="noopener noreferrer" target="_blank">@TiddlyWiki on Twitter</a> for the latest news</li><li>Chat at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> (development room coming soon)</li></ul><h2 class="">Other Forums</h2><ul><li><a class="tc-tiddlylink-external" href="https://www.reddit.com/r/TiddlyWiki5/" rel="noopener noreferrer" target="_blank">TiddlyWiki Subreddit</a></li><li>Chat with Gitter at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> !</li><li>Chat on Discord at <a class="tc-tiddlylink-external" href="https://discord.gg/HFFZVQ8" rel="noopener noreferrer" target="_blank">https://discord.gg/HFFZVQ8</a></li></ul><h3 class="">Documentation</h3><p>There is also a discussion group specifically for discussing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> documentation improvement initiatives: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/tiddlywikidocs" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/tiddlywikidocs</a>
<hr><h1 class="">TiddlyWiki MultiWikiServer</h1><p>UNDER DEVELOPMENT</p><p>This is a branch of <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> that adds the MultiWikiServer plugin.</p><h2 class="">Readme</h2><p>This plugin extends the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> 5 server running on Node.js to be able to host multiple wikis that can share content or be independent.</p><p>Installation</p><pre><code>git clone https://github.com/Jermolene/TiddlyWiki5.git --branch multi-wiki-support
cd TiddlyWiki5
npm install</code></pre><p>To start the server:</p><pre><code>npm start</code></pre><p>The <code>npm start</code> command is a shortcut for the following command:</p><pre><code>node ./tiddlywiki.js ./editions/multiwikiserver --listen</code></pre><p>Then visit the administration interface in a browser:</p><ul><li><a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a></li></ul><p>Note that changes are written to the topmost bag in a recipe.</p><p>Note that until syncing is improved it is necessary to use "Get latest changes from the server" to speed up propogation of changes.</p><p>To run the tests:</p><pre><code>./bin/test.sh</code></pre><h2 class="">Docs</h2><h1 class="">HTTP API</h1><p>The MultiWikiServer HTTP API provides access to resources hosted by the MWS store. It is based on <a class="tc-tiddlylink-external" href="https://tank.peermore.com/tanks/tiddlyweb/HTTP%20API" rel="noopener noreferrer" target="_blank">the API of TiddlyWeb</a>, first developed in 2008 by Chris Dent.</p><p>The design goals of the API are:</p><ul><li>To follow the principles of REST where practical</li><li>To present resources as nouns, not verbs</li></ul><p>General points about the design:</p><ul><li>In MWS there are no resources that end with / (except for the root path which is /)</li></ul><hr><p>Welcome to <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a>, a non-linear personal web notebook that anyone can use and keep forever, independently of any corporation.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> is a complete interactive wiki in <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/JavaScript.html">JavaScript</a>. It can be used as a single HTML file in the browser or as a powerful Node.js application. It is highly customisable: the entire user interface is itself implemented in hackable <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/WikiText.html">WikiText</a>.</p><p>Learn more and see it in action at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/</a></p><p>Developer documentation is in progress at <a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev/" rel="noopener noreferrer" target="_blank">https://tiddlywiki.com/dev/</a></p><h1 class="">Join the Community</h1><p>
<h2 class="">Official Forums</h2><p>The new official forum for talking about TiddlyWiki: requests for help, announcements of new releases and plugins, debating new features, or just sharing experiences. You can participate via the associated website, or subscribe via email.</p><p><a class="tc-tiddlylink-external" href="https://talk.tiddlywiki.org/" rel="noopener noreferrer" target="_blank">https://talk.tiddlywiki.org/</a></p><p>Note that talk.tiddlywiki.org is a community run service that we host and maintain ourselves. The modest running costs are covered by community contributions.</p><p>For the convenience of existing users, we also continue to operate the original TiddlyWiki group (hosted on Google Groups since 2005):</p><p><a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWiki" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWiki</a></p><h2 class="">Developer Forums</h2><p>There are several resources for developers to learn more about <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> and to discuss and contribute to its development.</p><ul><li><a class="tc-tiddlylink-external" href="https://tiddlywiki.com/dev" rel="noopener noreferrer" target="_blank">tiddlywiki.com/dev</a> is the official developer documentation</li><li>Get involved in the <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5" rel="noopener noreferrer" target="_blank">development on GitHub</a><ul><li><img class=" tc-image-loading" src="https://repobeats.axiom.co/api/embed/5a3bb51fd1ebe84a2da5548f78d2d74e456cebf3.svg"><span style="display:none;"></span></li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">Discussions</a> are for Q&amp;A and open-ended discussion</li><li><a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/issues" rel="noopener noreferrer" target="_blank">Issues</a> are for raising bug reports and proposing specific, actionable new ideas</li></ul></li><li>The older TiddlyWikiDev Google Group is now closed in favour of <a class="tc-tiddlylink-external" href="https://github.com/Jermolene/TiddlyWiki5/discussions" rel="noopener noreferrer" target="_blank">GitHub Discussions</a> but remains a useful archive: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/TiddlyWikiDev" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/TiddlyWikiDev</a><ul><li>An enhanced group search facility is available on <a class="tc-tiddlylink-external" href="https://www.mail-archive.com/tiddlywikidev@googlegroups.com/" rel="noopener noreferrer" target="_blank">mail-archive.com</a></li></ul></li><li>Follow <a class="tc-tiddlylink-external" href="http://twitter.com/#!/TiddlyWiki" rel="noopener noreferrer" target="_blank">@TiddlyWiki on Twitter</a> for the latest news</li><li>Chat at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> (development room coming soon)</li></ul><h2 class="">Other Forums</h2><ul><li><a class="tc-tiddlylink-external" href="https://www.reddit.com/r/TiddlyWiki5/" rel="noopener noreferrer" target="_blank">TiddlyWiki Subreddit</a></li><li>Chat with Gitter at <a class="tc-tiddlylink-external" href="https://gitter.im/TiddlyWiki/public" rel="noopener noreferrer" target="_blank">https://gitter.im/TiddlyWiki/public</a> !</li><li>Chat on Discord at <a class="tc-tiddlylink-external" href="https://discord.gg/HFFZVQ8" rel="noopener noreferrer" target="_blank">https://discord.gg/HFFZVQ8</a></li></ul><h3 class="">Documentation</h3><p>There is also a discussion group specifically for discussing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> documentation improvement initiatives: <a class="tc-tiddlylink-external" href="https://groups.google.com/group/tiddlywikidocs" rel="noopener noreferrer" target="_blank">https://groups.google.com/group/tiddlywikidocs</a>
</p>
</p><h1 class="">Installing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><ol><li>Install <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Node.js.html">Node.js</a><ul><li>Linux: <blockquote><div><em>Debian/Ubuntu</em>:<br><code>apt install nodejs</code><br>May need to be followed up by:<br><code>apt install npm</code></div><div><em>Arch Linux</em><br><code>yay -S tiddlywiki</code> <br>(installs node and tiddlywiki)</div></blockquote></li><li>Mac<blockquote><div><code>brew install node</code></div></blockquote></li><li>Android<blockquote><div><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Termux for Android</a></div></blockquote></li><li>Other <blockquote><div>See <a class="tc-tiddlylink-external" href="http://nodejs.org" rel="noopener noreferrer" target="_blank">http://nodejs.org</a></div></blockquote></li></ul></li><li>Open a command line terminal and type:<blockquote><div><code>npm install -g tiddlywiki</code></div><div>If it fails with an error you may need to re-run the command as an administrator:</div><div><code>sudo npm install -g tiddlywiki</code> (Mac/Linux)</div></blockquote></li><li>Ensure TiddlyWiki is installed by typing:<blockquote><div><code>tiddlywiki --version</code></div></blockquote><ul><li>In response, you should see <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> report its current version (eg "5.3.3". You may also see other debugging information reported.)</li></ul></li><li>Try it out:<ol><li><code>tiddlywiki mynewwiki --init server</code> to create a folder for a new wiki that includes server-related components</li><li><code>tiddlywiki mynewwiki --listen</code> to start <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></li><li>Visit <a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a> in your browser</li><li>Try editing and creating tiddlers</li></ol></li><li>Optionally, make an offline copy:<ul><li>click the <span class="doc-icon"><svg class="tc-image-save-button-dynamic tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt">
</p><h1 class="">Installing <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><ol><li>Install <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Node.js.html">Node.js</a><ul><li>Linux: <blockquote><div><em>Debian/Ubuntu</em>:<br><code>apt install nodejs</code><br>May need to be followed up by:<br><code>apt install npm</code></div><div><em>Arch Linux</em><br><code>yay -S tiddlywiki</code> <br>(installs node and tiddlywiki)</div></blockquote></li><li>Mac<blockquote><div><code>brew install node</code></div></blockquote></li><li>Android<blockquote><div><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Termux for Android</a></div></blockquote></li><li>Other <blockquote><div>See <a class="tc-tiddlylink-external" href="http://nodejs.org" rel="noopener noreferrer" target="_blank">http://nodejs.org</a></div></blockquote></li></ul></li><li>Open a command line terminal and type:<blockquote><div><code>npm install -g tiddlywiki</code></div><div>If it fails with an error you may need to re-run the command as an administrator:</div><div><code>sudo npm install -g tiddlywiki</code> (Mac/Linux)</div></blockquote></li><li>Ensure TiddlyWiki is installed by typing:<blockquote><div><code>tiddlywiki --version</code></div></blockquote><ul><li>In response, you should see <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> report its current version (eg "5.3.4-prerelease". You may also see other debugging information reported.)</li></ul></li><li>Try it out:<ol><li><code>tiddlywiki mynewwiki --init server</code> to create a folder for a new wiki that includes server-related components</li><li><code>tiddlywiki mynewwiki --listen</code> to start <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></li><li>Visit <a class="tc-tiddlylink-external" href="http://127.0.0.1:8080/" rel="noopener noreferrer" target="_blank">http://127.0.0.1:8080/</a> in your browser</li><li>Try editing and creating tiddlers</li></ol></li><li>Optionally, make an offline copy:<ul><li>click the <span class="doc-icon"><svg class="tc-image-save-button-dynamic tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt">
<g class="tc-image-save-button-dynamic-clean">
<path d="M120.783 34.33c4.641 8.862 7.266 18.948 7.266 29.646 0 35.347-28.653 64-64 64-35.346 0-64-28.653-64-64 0-35.346 28.654-64 64-64 18.808 0 35.72 8.113 47.43 21.03l2.68-2.68c3.13-3.13 8.197-3.132 11.321-.008 3.118 3.118 3.121 8.193-.007 11.32l-4.69 4.691zm-12.058 12.058a47.876 47.876 0 013.324 17.588c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48c14.39 0 27.3 6.332 36.098 16.362L58.941 73.544 41.976 56.578c-3.127-3.127-8.201-3.123-11.32-.005-3.123 3.124-3.119 8.194.006 11.319l22.617 22.617a7.992 7.992 0 005.659 2.347c2.05 0 4.101-.783 5.667-2.349l44.12-44.12z" fill-rule="evenodd"></path>
</g>
@ -9,10 +13,10 @@
<path d="M64.856912,0 C100.203136,0 128.856912,28.653776 128.856912,64 C128.856912,99.346224 100.203136,128 64.856912,128 C29.510688,128 0.856911958,99.346224 0.856911958,64 C0.856911958,28.653776 29.510688,0 64.856912,0 Z M64.856912,16 C38.347244,16 16.856912,37.490332 16.856912,64 C16.856912,90.509668 38.347244,112 64.856912,112 C91.3665799,112 112.856912,90.509668 112.856912,64 C112.856912,37.490332 91.3665799,16 64.856912,16 Z"></path>
<circle cx="65" cy="64" r="32"></circle>
</g>
</svg></span> <strong>save changes</strong> button in the sidebar, <strong>OR</strong></li><li><code>tiddlywiki mynewwiki --build index</code></li></ul></li></ol><p>The <code>-g</code> flag causes <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> to be installed globally. Without it, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> will only be available in the directory where you installed it.</p><p><div class="doc-icon-block"><div class="doc-block-icon"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg></div> If you are using Debian or Debian-based Linux and you are receiving a <code>node: command not found</code> error though node.js package is installed, you may need to create a symbolic link between <code>nodejs</code> and <code>node</code>. Consult your distro's manual and <code>whereis</code> to correctly create a link. See github <a class="tc-tiddlylink-external" href="http://github.com/Jermolene/TiddlyWiki5/issues/1434" rel="noopener noreferrer" target="_blank">issue 1434</a>. <br><br>Example Debian v8.0: <code>sudo ln -s /usr/bin/nodejs /usr/bin/node</code></div></p><p><br>
<div class="doc-icon-block"><div class="doc-block-icon"><svg class="tc-image-tip tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M64 128.242c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64-35.346 0-64 28.654-64 64 0 35.346 28.654 64 64 64zm11.936-36.789c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349C54.33 94.05 58.824 95.82 64 95.82c5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.456c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zM64.3 24.242c11.618 0 23.699 7.82 23.699 24.2S75.92 71.754 75.92 83.576c0 5.873-5.868 9.26-11.92 9.26s-12.027-3.006-12.027-9.26C51.973 71.147 40 65.47 40 48.442s12.683-24.2 24.301-24.2z" fill-rule="evenodd"></path></svg></div> You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code></div>
</p><h1 class="">Using <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>TiddlyWiki5 includes a set of commands for use on the command line to perform an extensive set of operations based on <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a>, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a>.</p><p>For example, the following command loads the tiddlers from a TiddlyWiki HTML file and then saves one of them in static HTML:</p><pre><code>tiddlywiki --verbose --load mywiki.html --rendertiddler ReadMe ./readme.html</code></pre><p>Running <code>tiddlywiki</code> from the command line boots the TiddlyWiki kernel, loads the core plugins and establishes an empty wiki store. It then sequentially processes the command line arguments from left to right. The arguments are separated with spaces.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.20.html"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg> New in: 5.1.20</a> First, there can be zero or more plugin references identified by the prefix <code>+</code> for plugin names or <code>++</code> for a path to a plugin folder. These plugins are loaded in addition to any specified in the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a>.</p><p>The next argument is the optional path to the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a> to be loaded. If not present, then the current directory is used.</p><p>The commands and their individual arguments follow, each command being identified by the prefix <code>--</code>.</p><pre><code>tiddlywiki [+&lt;pluginname&gt; | ++&lt;pluginpath&gt;] [&lt;wikipath&gt;] [--&lt;command&gt; [&lt;arg&gt;[,&lt;arg&gt;]]]</code></pre><p>For example:</p><pre><code>tiddlywiki --version
</svg></span> <strong>save changes</strong> button in the sidebar, <strong>OR</strong></li><li><code>tiddlywiki mynewwiki --build index</code></li></ul></li></ol><p>The <code>-g</code> flag causes <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> to be installed globally. Without it, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> will only be available in the directory where you installed it.</p><p><div class="doc-icon-block doc-warning"><div><strong>Warning</strong></div><div class="doc-block-icon"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg></div>If you are using Debian or Debian-based Linux and you are receiving a <code>node: command not found</code> error though node.js package is installed, you may need to create a symbolic link between <code>nodejs</code> and <code>node</code>. Consult your distro's manual and <code>whereis</code> to correctly create a link. See github <a class="tc-tiddlylink-external" href="http://github.com/Jermolene/TiddlyWiki5/issues/1434" rel="noopener noreferrer" target="_blank">issue 1434</a>. <br><br>Example Debian v8.0: <code>sudo ln -s /usr/bin/nodejs /usr/bin/node</code></div></p><p><br>
<div class="doc-icon-block doc-tip"><div><strong>Tip</strong></div><div class="doc-block-icon"><svg class="tc-image-tip tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M64 128.242c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64-35.346 0-64 28.654-64 64 0 35.346 28.654 64 64 64zm11.936-36.789c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349C54.33 94.05 58.824 95.82 64 95.82c5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.456c-.624 4.129-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zm0 4.492c-.624 4.13-5.73 7.349-11.936 7.349-6.206 0-11.312-3.22-11.936-7.349 2.266 2.597 6.76 4.366 11.936 4.366 5.175 0 9.67-1.769 11.936-4.366zM64.3 24.242c11.618 0 23.699 7.82 23.699 24.2S75.92 71.754 75.92 83.576c0 5.873-5.868 9.26-11.92 9.26s-12.027-3.006-12.027-9.26C51.973 71.147 40 65.47 40 48.442s12.683-24.2 24.301-24.2z" fill-rule="evenodd"></path></svg></div>You can also install prior versions like this: <br><code> npm install -g tiddlywiki@5.1.13</code></div>
</p><h1 class="">Using <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>TiddlyWiki5 includes a set of commands for use on the command line to perform an extensive set of operations based on <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a>, <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a>.</p><p>For example, the following command loads the tiddlers from a TiddlyWiki HTML file and then saves one of them in static HTML:</p><pre><code>tiddlywiki --verbose --load mywiki.html --rendertiddler ReadMe ./readme.html</code></pre><p>Running <code>tiddlywiki</code> from the command line boots the TiddlyWiki kernel, loads the core plugins and establishes an empty wiki store. It then sequentially processes the command line arguments from left to right. The arguments are separated with spaces.</p><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.20.html"><span class="tc-tiny-gap-right"><svg class="tc-image-info-button tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><g fill-rule="evenodd" transform="translate(.05)"><path d="M64 128c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64C28.654 0 0 28.654 0 64c0 35.346 28.654 64 64 64zm0-16c26.51 0 48-21.49 48-48S90.51 16 64 16 16 37.49 16 64s21.49 48 48 48z"></path><circle cx="64" cy="32" r="8"></circle><rect height="56" rx="8" width="16" x="56" y="48"></rect></g></svg></span>Introduced in v5.1.20</a> First, there can be zero or more plugin references identified by the prefix <code>+</code> for plugin names or <code>++</code> for a path to a plugin folder. These plugins are loaded in addition to any specified in the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a>.</p><p>The next argument is the optional path to the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolder</a> to be loaded. If not present, then the current directory is used.</p><p>The commands and their individual arguments follow, each command being identified by the prefix <code>--</code>.</p><pre><code>tiddlywiki [+&lt;pluginname&gt; | ++&lt;pluginpath&gt;] [&lt;wikipath&gt;] [--&lt;command&gt; [&lt;arg&gt;[,&lt;arg&gt;]]]</code></pre><p>For example:</p><pre><code>tiddlywiki --version
tiddlywiki +plugins/tiddlywiki/filesystem +plugins/tiddlywiki/tiddlyweb mywiki --listen
tiddlywiki ++./mygreatplugin mywiki --listen</code></pre><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.18.html"><svg class="tc-image-warning tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><path d="M57.072 11c3.079-5.333 10.777-5.333 13.856 0l55.426 96c3.079 5.333-.77 12-6.928 12H8.574c-6.158 0-10.007-6.667-6.928-12l55.426-96zM64 37c-4.418 0-8 3.582-8 7.994v28.012C56 77.421 59.59 81 64 81c4.418 0 8-3.582 8-7.994V44.994C72 40.579 68.41 37 64 37zm0 67a8 8 0 100-16 8 8 0 000 16z" fill-rule="evenodd"></path></svg> New in: 5.1.18</a> Commands such as the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/ListenCommand.html">ListenCommand</a> that support large numbers of parameters can use <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a> to make things less unwieldy. For example:</p><pre><code>tiddlywiki wikipath --listen username=jeremy port=8090</code></pre><p>See <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Commands.html">Commands</a> for a full listing of the available commands.
tiddlywiki ++./mygreatplugin mywiki --listen</code></pre><p><a class="tc-tiddlylink tc-tiddlylink-resolves doc-from-version" href="https://tiddlywiki.com/static/Release%25205.1.18.html"><span class="tc-tiny-gap-right"><svg class="tc-image-info-button tc-image-button" height="22pt" viewBox="0 0 128 128" width="22pt"><g fill-rule="evenodd" transform="translate(.05)"><path d="M64 128c35.346 0 64-28.654 64-64 0-35.346-28.654-64-64-64C28.654 0 0 28.654 0 64c0 35.346 28.654 64 64 64zm0-16c26.51 0 48-21.49 48-48S90.51 16 64 16 16 37.49 16 64s21.49 48 48 48z"></path><circle cx="64" cy="32" r="8"></circle><rect height="56" rx="8" width="16" x="56" y="48"></rect></g></svg></span>Introduced in v5.1.18</a> Commands such as the <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/ListenCommand.html">ListenCommand</a> that support large numbers of parameters can use <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a> to make things less unwieldy. For example:</p><pre><code>tiddlywiki wikipath --listen username=jeremy port=8090</code></pre><p>See <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Commands.html">Commands</a> for a full listing of the available commands.
</p><h1 class="">Upgrading <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a> on Node.js</h1><p>If you've installed <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki%2520on%2520Node.js.html">TiddlyWiki on Node.js</a> on the usual way, when a new version is released you can upgrade it with this command:</p><pre><code>npm update -g tiddlywiki</code></pre><p>On Mac or Linux you'll need to add <strong>sudo</strong> like this:</p><pre><code>sudo npm update -g tiddlywiki</code></pre><h1 class="">Also see</h1><p><ul class=""><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Building%2520TiddlyWikiClassic.html">Building TiddlyWikiClassic</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Customising%2520Tiddler%2520File%2520Naming.html">Customising Tiddler File Naming</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Environment%2520Variables%2520on%2520Node.js.html">Environment Variables on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Generating%2520Static%2520Sites%2520with%2520TiddlyWiki.html">Generating Static Sites with TiddlyWiki</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/How%2520to%2520build%2520a%2520TiddlyWiki5%2520from%2520individual%2520tiddlers.html">How to build a TiddlyWiki5 from individual tiddlers</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520custom%2520plugins%2520on%2520Node.js.html">Installing custom plugins on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520official%2520plugins%2520on%2520Node.js.html">Installing official plugins on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520TiddlyWiki%2520on%2520Microsoft%2520Internet%2520Information%2520Server.html">Internet Information Services</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Installing%2520TiddlyWiki%2520Prerelease%2520on%2520Node.js.html">Installing TiddlyWiki Prerelease on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/MultiTiddlerFiles.html">MultiTiddlerFiles</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/MultiTiddlerFileSyntax.html">MultiTiddlerFileSyntax</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/NamedCommandParameters.html">NamedCommandParameters</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Scripts%2520for%2520TiddlyWiki%2520on%2520Node.js.html">Scripts for TiddlyWiki on Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Serving%2520TW5%2520from%2520Android.html">Node.js on Termux</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlerFiles.html">TiddlerFiles</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/tiddlywiki.files%2520Files.html">tiddlywiki.files Files</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/tiddlywiki.info%2520Files.html">tiddlywiki.info Files</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWikiFolders.html">TiddlyWikiFolders</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Uninstalling%2520a%2520plugin%2520with%2520Node.js.html">Uninstalling a plugin with Node.js</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Using%2520a%2520custom%2520path%2520prefix%2520with%2520the%2520client-server%2520edition.html">Using a custom path prefix with the client-server edition</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Using%2520TiddlyWiki%2520for%2520GitHub%2520project%2520documentation.html">Using TiddlyWiki for GitHub project documentation</a></li><li><a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/Working%2520with%2520the%2520TiddlyWiki5%2520repository.html">Working with the TiddlyWiki5 repository</a></li></ul></p><p><em>This readme file was automatically generated by <a class="tc-tiddlylink tc-tiddlylink-resolves" href="https://tiddlywiki.com/static/TiddlyWiki.html">TiddlyWiki</a></em>
</p>