├── now.json ├── .gitignore ├── tiddlywiki ├── tiddlers │ ├── $__StoryList.tid │ ├── DefaultTiddlers.tid │ ├── Loading....tid │ ├── $__themes_tiddlywiki_vanilla_metrics_fontsize.tid │ ├── $__themes_tiddlywiki_vanilla_metrics_bodyfontsize.tid │ ├── $__config_PageControlButtons_Visibility_$__core_ui_Buttons_home.tid │ ├── $__core_ui_PageTemplate_sidebar.tid │ └── $__core_modules_parsers_wikiparser_rules_extlink.js.tid ├── plugins │ └── hyperdrive │ │ ├── plugin.info │ │ ├── tiddlywiki.files │ │ └── hyperdriveadaptor.js ├── themes │ └── snowwhite2 │ │ ├── plugin.info │ │ └── base.tid └── tiddlywiki.info ├── README.md ├── app.json ├── stores ├── networkStatus.js ├── documents.js └── shoppingList.js ├── Dockerfile ├── server ├── periodicRestart.js ├── middleware │ ├── serviceWorkerNoCache.js │ ├── redirectToHttps.js │ └── csp.js ├── makeImages.js ├── makeTiddlyWikiPlugins.js ├── makeServiceWorker.js ├── tiddlyWiki.js ├── index.js └── dbGateway.js ├── static ├── img │ ├── dat-hexagon.svg │ ├── ic_sync_black_24px.svg │ ├── ic_sync_problem_black_24px.svg │ ├── ic_sync_disabled_black_24px.svg │ ├── baseline-settings-20px.svg │ ├── dat-tiddlywiki.svg │ ├── Motovun Jack.svg │ └── bg-landing-page.svg └── manifest.webmanifest ├── index.css ├── .glitch-assets ├── .vscode └── launch.json ├── components ├── svgIcon.js ├── debugTools.js ├── shoppingListTitle.js ├── button.js ├── footer.js ├── header.js ├── customAlert.js ├── statusDisplay.js ├── githubButton.js └── writeStatus.js ├── lib ├── dumpWriters.js ├── downloadZip.js └── websocketGateway.js ├── LICENSE.md ├── views ├── create.js ├── addLink.js ├── shoppingList.js └── main.js ├── package.json ├── index.html ├── index.js └── CODE_OF_CONDUCT.md /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "docker" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | .data 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__StoryList.tid: -------------------------------------------------------------------------------- 1 | list: Loading... 2 | title: $:/StoryList 3 | 4 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/DefaultTiddlers.tid: -------------------------------------------------------------------------------- 1 | title: $:/DefaultTiddlers 2 | 3 | Loading... 4 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/Loading....tid: -------------------------------------------------------------------------------- 1 | created: 20180524045038440 2 | modified: 20180524045108376 3 | tags: 4 | title: Loading... 5 | 6 | Please wait while the Tiddlers load... -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__themes_tiddlywiki_vanilla_metrics_fontsize.tid: -------------------------------------------------------------------------------- 1 | created: 20180602112130025 2 | modified: 20180602112131348 3 | title: $:/themes/tiddlywiki/vanilla/metrics/fontsize 4 | 5 | 16px -------------------------------------------------------------------------------- /tiddlywiki/plugins/hyperdrive/plugin.info: -------------------------------------------------------------------------------- 1 | { 2 | "title": "$:/plugins/hyperdrive", 3 | "description": "Sync adaptor for hyperdrive", 4 | "author": "Jim Pick", 5 | "core-version": ">=5.0.0" 6 | } 7 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__themes_tiddlywiki_vanilla_metrics_bodyfontsize.tid: -------------------------------------------------------------------------------- 1 | created: 20180602112140983 2 | modified: 20180602112142290 3 | title: $:/themes/tiddlywiki/vanilla/metrics/bodyfontsize 4 | 5 | 16px -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__config_PageControlButtons_Visibility_$__core_ui_Buttons_home.tid: -------------------------------------------------------------------------------- 1 | created: 20180524050232060 2 | modified: 20180524050301524 3 | title: $:/config/PageControlButtons/Visibility/$:/core/ui/Buttons/home 4 | 5 | show -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dat-tiddlywiki 2 | 3 | ![Logo](https://dat-tiddlywiki.glitch.me/img/dat-tiddlywiki-96.png) 4 | 5 | [https://dat-tiddlywiki.glitch.me/](https://dat-tiddlywiki.glitch.me/) 6 | 7 | Work-in-progress 8 | 9 | # License 10 | 11 | MIT 12 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dat Shopping List", 3 | "description": "A demo of dat multiwriter support (hyperdrive+hyperdb) in a PWA", 4 | "repository": "https://github.com/jimpick/dat-shopping-list", 5 | "logo": "https://dat-shopping-list.glitch.me/img/dat-shopping-list.svg", 6 | "keywords": ["node", "dat"] 7 | } -------------------------------------------------------------------------------- /tiddlywiki/themes/snowwhite2/plugin.info: -------------------------------------------------------------------------------- 1 | { 2 | "title": "$:/themes/snowwhite2", 3 | "name": "Snow White 2", 4 | "author": "JeremyRuston", 5 | "core-version": ">=5.0.0", 6 | "plugin-type": "theme", 7 | "description": "Snow White 2", 8 | "dependents": ["$:/themes/tiddlywiki/vanilla"], 9 | "plugin-priority": "0" 10 | } 11 | -------------------------------------------------------------------------------- /stores/networkStatus.js: -------------------------------------------------------------------------------- 1 | module.exports = store 2 | 3 | function store (state, emitter) { 4 | updateOnlineStatus() 5 | window.addEventListener('online', updateOnlineStatus) 6 | window.addEventListener('offline', updateOnlineStatus) 7 | 8 | function updateOnlineStatus () { 9 | state.networkStatus = navigator.onLine 10 | emitter.emit('render') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon 2 | 3 | # Copy files 4 | WORKDIR /usr/src/app 5 | 6 | COPY components ./components 7 | COPY lib ./lib 8 | COPY server ./server 9 | COPY static ./static 10 | COPY stores ./stores 11 | COPY tiddlywiki ./tiddlywiki 12 | COPY views ./views 13 | COPY package.json index.* ./ 14 | 15 | RUN npm install 16 | 17 | EXPOSE 5000 18 | 19 | CMD ["npm", "start"] 20 | 21 | -------------------------------------------------------------------------------- /server/periodicRestart.js: -------------------------------------------------------------------------------- 1 | // Restart after an interval so that the demo doesn't seed content forever 2 | // Active clients should re-connect 3 | 4 | module.exports = periodicRestart 5 | 6 | function periodicRestart (intervalMinutes) { 7 | setTimeout(() => { 8 | console.log(`Planned periodic restart after ${intervalMinutes} minutes.`) 9 | process.exit(0) 10 | }, intervalMinutes * 60 * 1000) 11 | } 12 | -------------------------------------------------------------------------------- /static/img/dat-hexagon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /static/img/ic_sync_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/middleware/serviceWorkerNoCache.js: -------------------------------------------------------------------------------- 1 | const nocache = require('nocache') 2 | 3 | module.exports = serviceWorkerNoCache 4 | 5 | const nocacheMiddleware = nocache() 6 | 7 | function serviceWorkerNoCache (req, res, next) { 8 | if ( 9 | req.url === '/' || 10 | req.url === '/sw.js' || 11 | req.url === '/index.js' 12 | ) { 13 | return nocacheMiddleware(req, res, next) 14 | } 15 | next() 16 | } 17 | -------------------------------------------------------------------------------- /tiddlywiki/plugins/hyperdrive/tiddlywiki.files: -------------------------------------------------------------------------------- 1 | { 2 | "tiddlers": [ 3 | { 4 | "file": "../../../.data/tiddlywiki-plugins/hyperdrive/hyperdriveadaptor.js", 5 | "fields": { 6 | "type": "application/javascript", 7 | "title": "$:/plugins/hyperdrive/hyperdriveadaptor.js", 8 | "module-type": "syncadaptor" 9 | }, 10 | "prefix": "", 11 | "suffix": "" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: flex; 3 | flex-direction: column; 4 | font-family: 'Source Sans Pro','PT Sans',Calibri,sans-serif; 5 | margin: 0 auto; 6 | overflow-x: hidden; 7 | max-width: 800px; 8 | } 9 | 10 | * { 11 | outline-color: var(--color-green); 12 | } 13 | 14 | .link { 15 | text-decoration: none; 16 | color: var(--color-green); 17 | } 18 | 19 | .link:focus { 20 | outline: 1px dotted currentColor; 21 | } 22 | -------------------------------------------------------------------------------- /server/middleware/redirectToHttps.js: -------------------------------------------------------------------------------- 1 | module.exports = redirectToHttps 2 | 3 | function redirectToHttps (req, res, next) { 4 | // Glitch has a proxy 5 | const xfpHeader = req.headers['x-forwarded-proto'] 6 | if (!xfpHeader.match(/^https/)) { 7 | const redirectUrl = 'https://' + req.headers['host'] + req.url 8 | res.writeHead(301, {Location: redirectUrl}) 9 | res.end() 10 | } else { 11 | next() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"dat-hexagon.svg","date":"2018-04-17T01:25:47.670Z","url":"https://cdn.glitch.com/9f4aafd1-7795-46ec-a944-a7f7ffd4da84%2Fdat-hexagon.svg","type":"image/svg+xml","size":388,"imageWidth":240,"imageHeight":240,"thumbnail":"https://cdn.glitch.com/9f4aafd1-7795-46ec-a944-a7f7ffd4da84%2Fdat-hexagon.svg","thumbnailWidth":240,"thumbnailHeight":240,"dominantColor":null,"uuid":"ortBJorRpyLmCzAe"} 2 | {"uuid":"ortBJorRpyLmCzAe","deleted":true} 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/server/index.js" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /components/svgIcon.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const raw = require('choo/html/raw') 3 | const html = require('choo/html') 4 | 5 | class SvgIcon extends Component { 6 | constructor () { 7 | super() 8 | this.svgData = null 9 | } 10 | 11 | createElement (data) { 12 | this.svgData = data 13 | return html`${raw(data)}` 14 | } 15 | 16 | update () { 17 | return false 18 | } 19 | } 20 | 21 | module.exports = SvgIcon 22 | -------------------------------------------------------------------------------- /static/img/ic_sync_problem_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/debugTools.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | 4 | module.exports = debugTools 5 | 6 | const prefix = css` 7 | :host { 8 | margin: 2rem; 9 | } 10 | ` 11 | 12 | function debugTools (state, emit) { 13 | if (!state.devMode) return null 14 | return html` 15 |
16 | Debug tools: ${' '} 17 | Download Zip 18 |
19 | ` 20 | 21 | function downloadZip (event) { 22 | emit('downloadZip') 23 | event.preventDefault() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dat TiddlyWiki", 3 | "short_name": "TiddlyWiki", 4 | "description": "A demo of peer-to-peer TiddlyWiki", 5 | "lang": "en-US", 6 | "start_url": "/", 7 | "display": "standalone", 8 | "orientation": "portrait", 9 | "theme_color": "#2ACA4B", 10 | "background_color": "#fff", 11 | "icons": [ 12 | { 13 | "src": "/img/dat-tiddlywiki-192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "/img/dat-tiddlywiki-512.png", 19 | "sizes": "512x512", 20 | "type": "image/png" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /static/img/ic_sync_disabled_black_24px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /server/middleware/csp.js: -------------------------------------------------------------------------------- 1 | const csp = require('helmet-csp') 2 | 3 | module.exports = csp({ 4 | directives: { 5 | defaultSrc: ["'self'"], 6 | imgSrc: ["'self'", 'https://cdn.glitch.com'], 7 | connectSrc: [ 8 | 'https://api.github.com', 9 | (req, res) => { 10 | // Glitch has a proxy 11 | const xfpHeader = req.headers['x-forwarded-proto'] 12 | if (!xfpHeader || !xfpHeader.match(/^https/)) { 13 | return 'ws://' + req.headers['host'] 14 | } else { 15 | return 'wss://' + req.headers['host'] 16 | } 17 | } 18 | ], 19 | scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 20 | styleSrc: ["'self'", "'unsafe-inline'"] 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /server/makeImages.js: -------------------------------------------------------------------------------- 1 | const sharp = require('sharp') 2 | 3 | module.exports = makeImages 4 | 5 | function makeImages (cb) { 6 | console.log('Making images') 7 | const base = 'dat-tiddlywiki' 8 | const sizes = [16, 32, 96, 120, 152, 167, 180, 192, 196, 512] 9 | 10 | sizes.reduce( 11 | (promise, size) => { 12 | console.log('Making png', base, size) 13 | return promise 14 | .then(() => { 15 | return sharp(`./static/img/${base}.svg`) 16 | .resize(size, size) 17 | .background('white') 18 | .flatten() 19 | .toFile(`./.data/img/${base}-${size}.png`) 20 | }) 21 | }, 22 | Promise.resolve() 23 | ).then(() => cb()) 24 | .catch(err => { console.error('Error', err) }) 25 | } 26 | -------------------------------------------------------------------------------- /static/img/baseline-settings-20px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__core_ui_PageTemplate_sidebar.tid: -------------------------------------------------------------------------------- 1 | created: 20180530001033382 2 | modified: 20180530001335960 3 | tags: $:/tags/PageTemplate 4 | title: $:/core/ui/PageTemplate/sidebar 5 | 6 | <$scrollable fallthrough="no" class="tc-sidebar-scrollable"> 7 | 8 |
9 | 10 | <$reveal state="$:/state/sidebar" type="match" text="yes" default="yes" retain="yes" animate="yes"> 11 | 12 |

13 | 14 | <$transclude tiddler="$:/SiteTitle" mode="inline"/> 15 | 16 |

17 | 18 |
19 | 20 | <$transclude tiddler="$:/SiteSubtitle" mode="inline"/> 21 | 22 |
23 | 24 | Authorize/Sync | All Wikis 25 | 26 | {{||$:/core/ui/PageTemplate/pagecontrols}} 27 | 28 | <$transclude tiddler="$:/core/ui/SideBarLists" mode="inline"/> 29 | 30 | 31 | 32 |
33 | 34 | -------------------------------------------------------------------------------- /server/makeTiddlyWikiPlugins.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const mkdirp = require('mkdirp') 3 | const path = require('path') 4 | const browserify = require('browserify') 5 | 6 | module.exports = makeTiddlyWikiPlugins 7 | 8 | function makeTiddlyWikiPlugins (cb) { 9 | console.log('Making TiddlyWiki plugins') 10 | const dest = path.join('.data', 'tiddlywiki-plugins', 'hyperdrive') 11 | mkdirp.sync(dest) 12 | const filename = 'hyperdriveadaptor.js' 13 | const output = fs.createWriteStream(path.join(dest, filename)) 14 | browserify({ 15 | entries: [path.join('tiddlywiki', 'plugins', 'hyperdrive', filename)], 16 | standalone: 'hyperdriveadaptor', 17 | cache: {}, 18 | packageCache: {} 19 | }) 20 | .on('error', err => { 21 | console.error('browserify error', err) 22 | process.exit(1) 23 | }) 24 | .bundle() 25 | .pipe(output) 26 | output.on('finish', () => { 27 | console.log('Done') 28 | cb() 29 | }) 30 | } -------------------------------------------------------------------------------- /lib/dumpWriters.js: -------------------------------------------------------------------------------- 1 | const prettyHash = require('pretty-hash') 2 | 3 | module.exports = dumpWriters 4 | 5 | function dumpWriters (archive) { 6 | const db = archive.db 7 | console.log( 8 | 'Connected Peers:', 9 | db.source.peers.filter(peer => !!peer.remoteId).length 10 | ) 11 | console.log('Writers:') 12 | db._writers.forEach(writer => { 13 | console.log( 14 | ` ${writer._id} ${writer._feed.key.toString('hex')} ` + 15 | `dk: ${prettyHash(writer._feed.discoveryKey)} ` + 16 | `${writer._feed.length} R${writer._feed.remoteLength} ` 17 | ) 18 | }) 19 | console.log('Content feeds:') 20 | db.contentFeeds.forEach((feed, index) => { 21 | if (feed) { 22 | console.log( 23 | ` ${index} ${feed.key.toString('hex')} ` + 24 | `dk: ${prettyHash(feed.discoveryKey)} ` + 25 | `${feed.length} R${feed.remoteLength} ` 26 | ) 27 | } else { 28 | console.log(' ', index, 'No feed') 29 | } 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jim Pick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/shoppingListTitle.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const copy = require('clipboard-copy') 4 | const prettyHash = require('pretty-hash') 5 | const customAlert = require('./customAlert') 6 | 7 | module.exports = title 8 | 9 | const prefix = css` 10 | :host { 11 | position: relative; 12 | h1 { 13 | margin: 0 0 0.5em 0; 14 | } 15 | .hash { 16 | font-size: 12px; 17 | font-family: monospace; 18 | position: absolute; 19 | top: -0.6rem; 20 | right: 0; 21 | cursor: pointer; 22 | } 23 | } 24 | ` 25 | 26 | function title (state, emit) { 27 | return html` 28 |
29 |

${state.docTitle}

30 |
31 | ${prettyHash(state.key)} 32 |
33 |
34 | ` 35 | 36 | function copyUrl (event) { 37 | copy(document.location.href).then(() => { 38 | customAlert.show('Shopping list URL copied to clipboard') 39 | }) 40 | } 41 | 42 | function keydown (event) { 43 | if (event.key === ' ' || event.key === 'Enter') { 44 | event.target.click() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /components/button.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | 4 | const prefix = css` 5 | :host { 6 | color: var(--color-white); 7 | background-color: var(--color-green); 8 | font-size: 1rem; 9 | font-weight: 700; 10 | text-decoration: none; 11 | padding: 0.5rem 1rem; 12 | display: inline-block; 13 | backface-visibility: hidden; 14 | transform: translateZ(0); 15 | transition: transform .25s ease-out,-webkit-transform .25s ease-out; 16 | border-color: transparent; 17 | &:active { 18 | transform: scale(0.9); 19 | } 20 | &:focus, &:hover { 21 | transform: scale(1.05); 22 | } 23 | -webkit-appearance: none; 24 | -moz-appearance: none; 25 | border-radius: 0; 26 | cursor: pointer; 27 | &::-moz-focus-inner { 28 | border: 0; 29 | } 30 | } 31 | ` 32 | 33 | module.exports = { 34 | button, 35 | submit 36 | } 37 | 38 | function button (label, onclick) { 39 | return html` 40 | 43 | ` 44 | } 45 | 46 | function submit (label, onclick) { 47 | return html` 48 | 49 | ` 50 | } 51 | -------------------------------------------------------------------------------- /server/makeServiceWorker.js: -------------------------------------------------------------------------------- 1 | const workboxBuild = require('workbox-build') 2 | 3 | module.exports = makeServiceWorker 4 | 5 | function makeServiceWorker (cb) { 6 | console.log('Making service worker') 7 | workboxBuild 8 | .generateSW({ 9 | swDest: '.data/sw.js', 10 | importWorkboxFrom: 'local', 11 | skipWaiting: true, 12 | clientsClaim: true, 13 | navigateFallback: '/', 14 | navigateFallbackWhitelist: [/^\/doc/, /^\/create/, /^\/add-link/], 15 | globDirectory: '.', 16 | globPatterns: ['index.html', 'static/manifest.webmanifest', 'static/**/*.svg', '.data/**/*.png'], 17 | modifyUrlPrefix: { 18 | 'static': '', 19 | '.data': '' 20 | }, 21 | templatedUrls: { 22 | '/': [ 'views/main.js' ] 23 | }, 24 | runtimeCaching: [ 25 | { 26 | urlPattern: /\/index.js$/, 27 | handler: 'staleWhileRevalidate' 28 | }, 29 | { 30 | urlPattern: new RegExp('^https://cdn.glitch.com/'), 31 | handler: 'staleWhileRevalidate' 32 | }, 33 | { 34 | urlPattern: new RegExp('^https://buttons.github.io/'), 35 | handler: 'staleWhileRevalidate' 36 | }, 37 | { 38 | urlPattern: new RegExp('^https://api.github.com/'), 39 | handler: 'staleWhileRevalidate' 40 | } 41 | ] 42 | }) 43 | .then(() => cb()) 44 | .catch(cb) 45 | } 46 | -------------------------------------------------------------------------------- /components/footer.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const GitHubButton = require('./githubButton') 4 | 5 | const prefix = css` 6 | :host { 7 | margin-top: 0.2rem; 8 | margin-left: 1rem; 9 | margin-right: 1rem; 10 | margin-bottom: 2rem; 11 | display: flex; 12 | flex-wrap: wrap; 13 | align-items: center; 14 | 15 | & > * { 16 | margin: 0.1rem; 17 | } 18 | 19 | & > a { 20 | margin-right: 0.2rem; 21 | } 22 | 23 | #more { 24 | justify-self: end; 25 | width: 7rem; 26 | margin-left: auto; 27 | } 28 | 29 | .github-button { 30 | opacity: 0; 31 | } 32 | } 33 | ` 34 | 35 | module.exports = footer 36 | 37 | function footer (state) { 38 | const {glitchAppName, gitHubRepoName} = state 39 | const ghButton = state.cache(GitHubButton, 'gitHubButton', gitHubRepoName).render() 40 | return html` 41 | 52 | ` 53 | } 54 | -------------------------------------------------------------------------------- /components/header.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const statusDisplay = require('./statusDisplay') 4 | 5 | const prefix = css` 6 | :host { 7 | border-bottom: 1px solid var(--color-neutral-10); 8 | flex: 0 64px; 9 | color: var(--color-neutral); 10 | font-weight: 700; 11 | font-size: 1.5rem; 12 | display: flex; 13 | padding-left: 1rem; 14 | padding-right: 1rem; 15 | position: relative; 16 | a { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | text-decoration: none; 21 | transition: opacity .15s ease-in; 22 | &:hover, &:focus { 23 | opacity: 0.5; 24 | } 25 | img { 26 | width: 2rem; 27 | height: 2rem; 28 | margin-right: 0.5rem; 29 | transition: transform .5s ease-in-out; 30 | &:hover, &:focus { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | } 35 | .title { 36 | color: var(--color-neutral-60); 37 | white-space: nowrap; 38 | } 39 | .first-word { 40 | color: var(--color-neutral); 41 | } 42 | } 43 | ` 44 | 45 | module.exports = header 46 | 47 | function header (state) { 48 | return html` 49 | 58 | ` 59 | } 60 | -------------------------------------------------------------------------------- /views/create.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const header = require('../components/header') 4 | const button = require('../components/button') 5 | 6 | const prefix = css` 7 | :host { 8 | .content { 9 | margin: 1em; 10 | } 11 | input[type="text"] { 12 | width: 100%; 13 | font-size: 1.5rem; 14 | } 15 | } 16 | ` 17 | 18 | module.exports = createView 19 | 20 | function createView (state, emit) { 21 | emit('DOMTitleChange', 'Dat TiddlyWiki - Create') 22 | const input = html`` 23 | input.isSameNode = function (target) { 24 | return (target && target.nodeName && target.nodeName === 'INPUT') 25 | } 26 | 27 | return html` 28 | 29 | ${header(state)} 30 |
31 |

32 | Enter a name for your new TiddlyWiki 33 |

34 |
35 | ${input} 36 |

37 | ${button.submit('Submit')} 38 |

39 |
40 |
41 | 42 | ` 43 | 44 | function submit (event) { 45 | const docName = event.target.querySelector('input').value 46 | if (docName) { 47 | const textInput = event.target.querySelector('input[type="text"]') 48 | textInput.setAttribute('disabled', 'disabled') 49 | const submitButton = event.target.querySelector('input[type="submit"]') 50 | submitButton.setAttribute('disabled', 'disabled') 51 | emit('createDoc', docName) 52 | } 53 | event.preventDefault() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dat-tiddlywiki", 3 | "version": "0.0.1", 4 | "description": "Multiwriter Dat + Tiddlywiki", 5 | "author": "Jim Pick (@jimpick)", 6 | "main": "server/index.js", 7 | "bin": { 8 | "dat-tiddlywiki": "./server/index.js" 9 | }, 10 | "scripts": { 11 | "start": "node server" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jimpick/dat-tiddlywiki.git" 16 | }, 17 | "dependencies": { 18 | "@jimpick/hyperdrive-hyperdb-backend": "^9.12.3-10", 19 | "automerge": "^0.7.8", 20 | "brfs": "^1.6.1", 21 | "budo": "^11.2.0", 22 | "choo": "^6.11.0-preview1", 23 | "choo-service-worker": "^2.4.0", 24 | "clipboard-copy": "^2.0.0", 25 | "compression": "^1.7.2", 26 | "dat-colors": "^3.5.1", 27 | "deep-equal": "^1.0.1", 28 | "diff": "^3.5.0", 29 | "express": "^4.16.3", 30 | "express-ws": "^3.0.0", 31 | "file-saver": "^1.3.8", 32 | "focus-trap": "^2.4.4", 33 | "helmet-csp": "^2.7.0", 34 | "hsts": "^2.1.0", 35 | "hyperdiscovery": "^7.1.0", 36 | "jszip": "^3.1.5", 37 | "mkdirp": "^0.5.1", 38 | "monotonic-timestamp-base36": "^1.0.0", 39 | "nocache": "^2.0.0", 40 | "pretty-hash": "^1.0.1", 41 | "random-access-idb": "^1.0.4", 42 | "random-access-memory": "^3.0.0", 43 | "sharp": "^0.20.2", 44 | "sheetify": "^7.3.2", 45 | "sheetify-nested": "^1.0.2", 46 | "thunky": "^1.0.2", 47 | "tiddlywiki": "^5.1.16", 48 | "to-buffer": "^1.1.1", 49 | "websocket-stream": "^5.1.2", 50 | "workbox-build": "^3.2.0" 51 | }, 52 | "engines": { 53 | "node": "9.x" 54 | }, 55 | "license": "MIT" 56 | } 57 | -------------------------------------------------------------------------------- /views/addLink.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const header = require('../components/header') 4 | const button = require('../components/button') 5 | const customAlert = require('../components/customAlert') 6 | 7 | const prefix = css` 8 | :host { 9 | .content { 10 | margin: 1em; 11 | } 12 | input[type="text"] { 13 | width: 100%; 14 | font-size: 1.5rem; 15 | } 16 | } 17 | ` 18 | 19 | module.exports = addLinkView 20 | 21 | function addLinkView (state, emit) { 22 | emit('DOMTitleChange', 'Dat Shopping List - Add Link') 23 | const input = html`` 24 | input.isSameNode = function (target) { 25 | return (target && target.nodeName && target.nodeName === 'INPUT') 26 | } 27 | 28 | return html` 29 | 30 | ${header(state)} 31 |
32 |

33 | Paste in a URL link or a hexadecimal key 34 |

35 |
36 | ${input} 37 |

38 | ${button.submit('Submit')} 39 |

40 |
41 |
42 | ${customAlert.alertBox(state, emit)} 43 | 44 | ` 45 | 46 | function submit (event) { 47 | const link = event.target.querySelector('input').value 48 | if (link) { 49 | const textInput = event.target.querySelector('input[type="text"]') 50 | textInput.setAttribute('disabled', 'disabled') 51 | const submitButton = event.target.querySelector('input[type="submit"]') 52 | submitButton.setAttribute('disabled', 'disabled') 53 | emit('addLink', link) 54 | } 55 | event.preventDefault() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Dat TiddlyWiki 27 | 28 | 29 | 30 | Generating bundle / loading... 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /lib/downloadZip.js: -------------------------------------------------------------------------------- 1 | const JSZip = require('jszip') 2 | const {saveAs} = require('file-saver') 3 | 4 | module.exports = downloadZip 5 | 6 | function downloadZip (archive) { 7 | const keyHex = archive.key.toString('hex') 8 | const storage = archive.db._storage 9 | const request = window.indexedDB.open('doc-' + keyHex) 10 | request.onerror = function (event) { 11 | console.log('IndexedDB error') 12 | } 13 | request.onsuccess = function (event) { 14 | const db = event.target.result 15 | const objectStore = db.transaction('data').objectStore('data') 16 | const files = [] 17 | objectStore.openCursor().onsuccess = function (event) { 18 | const cursor = event.target.result 19 | if (cursor) { 20 | const match = cursor.key.match(/^(.*)\0length$/) 21 | if (match) { 22 | const filename = match[1] 23 | const length = cursor.value 24 | console.log('zipping', filename, length) 25 | files.push({filename, length}) 26 | } 27 | cursor.continue() 28 | } else { 29 | zipUpFiles(keyHex, storage, files) 30 | } 31 | } 32 | } 33 | } 34 | 35 | function zipUpFiles (keyHex, storage, files) { 36 | const zip = new JSZip() 37 | addFilesToZip(() => { 38 | zip.generateAsync({type: 'blob'}).then(blob => { 39 | saveAs(blob, `hyperdrive-${keyHex}.zip`) 40 | }) 41 | }) 42 | 43 | function addFilesToZip (cb) { 44 | if (files.length === 0) return cb() 45 | const {filename, length} = files.shift() 46 | setTimeout(() => { 47 | const file = storage(filename) 48 | file.read(0, length, (err, buf) => { 49 | if (err) throw err 50 | zip.file(filename, buf) 51 | addFilesToZip(cb) 52 | }) 53 | }, 0) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlers/$__core_modules_parsers_wikiparser_rules_extlink.js.tid: -------------------------------------------------------------------------------- 1 | created: 20180603020638507 2 | modified: 20180603020854970 3 | module-type: wikirule 4 | title: $:/core/modules/parsers/wikiparser/rules/extlink.js 5 | type: application/javascript 6 | 7 | /*\ 8 | title: $:/core/modules/parsers/wikiparser/rules/extlink.js 9 | type: application/javascript 10 | module-type: wikirule 11 | 12 | Wiki text inline rule for external links. For example: 13 | 14 | ``` 15 | An external link: https://www.tiddlywiki.com/ 16 | 17 | A suppressed external link: ~http://www.tiddlyspace.com/ 18 | ``` 19 | 20 | External links can be suppressed by preceding them with `~`. 21 | 22 | \*/ 23 | (function(){ 24 | 25 | /*jslint node: true, browser: true */ 26 | /*global $tw: false */ 27 | "use strict"; 28 | 29 | exports.name = "extlink"; 30 | exports.types = {inline: true}; 31 | 32 | exports.init = function(parser) { 33 | this.parser = parser; 34 | // Regexp to match 35 | this.matchRegExp = /~?(?:file|http|https|mailto|ftp|irc|news|data|skype|dat):[^\s<>{}\[\]`|"\\^]+(?:\/|\b)/mg; 36 | }; 37 | 38 | exports.parse = function() { 39 | // Move past the match 40 | this.parser.pos = this.matchRegExp.lastIndex; 41 | // Create the link unless it is suppressed 42 | if(this.match[0].substr(0,1) === "~") { 43 | return [{type: "text", text: this.match[0].substr(1)}]; 44 | } else { 45 | return [{ 46 | type: "element", 47 | tag: "a", 48 | attributes: { 49 | href: {type: "string", value: this.match[0]}, 50 | "class": {type: "string", value: "tc-tiddlylink-external"}, 51 | target: {type: "string", value: "_blank"}, 52 | rel: {type: "string", value: "noopener noreferrer"} 53 | }, 54 | children: [{ 55 | type: "text", text: this.match[0] 56 | }] 57 | }]; 58 | } 59 | }; 60 | 61 | })(); 62 | -------------------------------------------------------------------------------- /tiddlywiki/tiddlywiki.info: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Basic client-server edition", 3 | "plugins": [ 4 | "hyperdrive", 5 | "tiddlywiki/filesystem", 6 | "tiddlywiki/highlight" 7 | ], 8 | "themes": [ 9 | "tiddlywiki/vanilla", 10 | "snowwhite2" 11 | ], 12 | "build": { 13 | "index": [ 14 | "--rendertiddler", 15 | "$:/plugins/tiddlywiki/tiddlyweb/save/offline", 16 | "index.html", 17 | "text/plain" 18 | ], 19 | "externalimages": [ 20 | "--savetiddlers", 21 | "[is[image]]", 22 | "images", 23 | "--setfield", 24 | "[is[image]]", 25 | "_canonical_uri", 26 | "$:/core/templates/canonical-uri-external-image", 27 | "text/plain", 28 | "--setfield", 29 | "[is[image]]", 30 | "text", 31 | "", 32 | "text/plain", 33 | "--rendertiddler", 34 | "$:/plugins/tiddlywiki/tiddlyweb/save/offline", 35 | "externalimages.html", 36 | "text/plain" 37 | ], 38 | "static": [ 39 | "--rendertiddler", 40 | "$:/core/templates/static.template.html", 41 | "static.html", 42 | "text/plain", 43 | "--rendertiddler", 44 | "$:/core/templates/alltiddlers.template.html", 45 | "alltiddlers.html", 46 | "text/plain", 47 | "--rendertiddlers", 48 | "[!is[system]]", 49 | "$:/core/templates/static.tiddler.html", 50 | "static", 51 | "text/plain", 52 | "--rendertiddler", 53 | "$:/core/templates/static.template.css", 54 | "static/static.css", 55 | "text/plain" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/tiddlyWiki.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const mkdirp = require('mkdirp') 4 | const tiddlywikiBoot = require('tiddlywiki/boot/boot') 5 | 6 | // See: https://gist.github.com/Arlen22/bbd852f68e328165e49f 7 | 8 | /* 9 | process.env['TIDDLYWIKI_THEME_PATH'] = path.resolve( 10 | __dirname, '..', 'tiddlywiki', 'themes' 11 | ) 12 | process.env['TIDDLYWIKI_PLUGIN_PATH'] = path.resolve( 13 | __dirname, '..', 'tiddlywiki', 'plugins' 14 | ) 15 | */ 16 | 17 | const wikis = {} 18 | 19 | function getWiki(key) { 20 | if (wikis[key]) return wikis[key] 21 | // const wikiPath = path.join('.data', 'wikis', key) 22 | // mkdirp.sync(wikiPath) 23 | // const tiddlyInfo = fs.readFileSync('tiddlywiki/tiddlywiki.info') 24 | // fs.writeFileSync(path.join(wikiPath, 'tiddlywiki.info'), tiddlyInfo) 25 | $tw = tiddlywikiBoot.TiddlyWiki() 26 | // $tw.boot.argv = [wikiPath] 27 | $tw.boot.argv = ['tiddlywiki'] 28 | $tw.boot.boot() 29 | /* 30 | $tw.wiki.addTiddler({ 31 | text: `$protocol$//$host$/doc/${key}/tw/`, 32 | title: '$:/config/tiddlyweb/host' 33 | }) 34 | */ 35 | const serverCommand = $tw.modules.execute( 36 | 'tiddlywiki/core/modules/commands/server.js' 37 | ).Command 38 | const command = new serverCommand([], {wiki: $tw.wiki}) 39 | const server = command.server 40 | server.set({ 41 | rootTiddler: "$:/core/save/all", 42 | renderType: "text/plain", 43 | serveType: "text/html", 44 | username: "", 45 | password: "", 46 | pathPrefix: `/doc/${key}/tw/` 47 | }) 48 | const requestHandler = server.requestHandler.bind(server) 49 | wikis[key] = { 50 | command, 51 | requestHandler 52 | } 53 | return wikis[key] 54 | } 55 | 56 | function tiddlyWikiRequest (req, res, next) { 57 | const wiki = getWiki(req.params.key) 58 | req.url = req.url.replace(/^.*\/tw\/?/, '/') 59 | wiki.requestHandler(req, res, next) 60 | } 61 | 62 | module.exports = tiddlyWikiRequest 63 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | const chooServiceWorker = require('choo-service-worker') 3 | const css = require('sheetify') 4 | 5 | const networkStatusStore = require('./stores/networkStatus') 6 | const documentsStore = require('./stores/documents') 7 | const shoppingListStore = require('./stores/shoppingList') 8 | 9 | const mainView = require('./views/main') 10 | const createView = require('./views/create') 11 | const addLinkView = require('./views/addLink') 12 | const shoppingListView = require('./views/shoppingList') 13 | 14 | css('dat-colors') 15 | css('./index.css') 16 | 17 | const app = choo() 18 | 19 | if (document.location.protocol === 'https:') { 20 | app.use(require('choo-service-worker/clear')()) 21 | // app.use(chooServiceWorker()) 22 | app.use((state, emitter) => { 23 | emitter.on('sw:installed', () => { console.log('sw:installed') }) 24 | emitter.on('sw:updated', () => { console.log('sw:updated') }) 25 | emitter.on('sw:redundant', () => { console.log('sw:redundant') }) 26 | if (navigator.serviceWorker) { 27 | console.log('Service worker controller', navigator.serviceWorker.controller) 28 | navigator.serviceWorker.getRegistrations() 29 | .then(registrations => { 30 | console.log('Service worker registrations', registrations) 31 | }) 32 | navigator.serviceWorker.ready.then(serviceWorker => { 33 | console.log('Service worker ready', serviceWorker) 34 | state.serviceWorker = true 35 | }) 36 | } 37 | }) 38 | } 39 | 40 | app.use(state => { 41 | state.glitchAppName = 'dat-tiddlywiki' 42 | state.gitHubRepoName = 'jimpick/dat-tiddlywiki' 43 | state.devMode = false 44 | state.devLabel = 'a' 45 | }) 46 | app.use(networkStatusStore) 47 | app.use(documentsStore) 48 | app.use(shoppingListStore) 49 | 50 | app.route('/', mainView) 51 | app.route('/create', createView) 52 | app.route('/add-link', addLinkView) 53 | app.route('/doc/:key', shoppingListView) 54 | 55 | app.mount('body') 56 | -------------------------------------------------------------------------------- /static/img/dat-tiddlywiki.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const budo = require('budo') 5 | const express = require('express') 6 | const compression = require('compression') 7 | const hsts = require('hsts') 8 | const mkdirp = require('mkdirp') 9 | const dbGateway = require('./dbGateway') 10 | const serviceWorkerNoCache = require('./middleware/serviceWorkerNoCache') 11 | const redirectToHttps = require('./middleware/redirectToHttps') 12 | const csp = require('./middleware/csp') 13 | const makeServiceWorker = require('./makeServiceWorker') 14 | const makeImages = require('./makeImages') 15 | const makeTiddlyWikiPlugins = require('./makeTiddlyWikiPlugins') 16 | const periodicRestart = require('./periodicRestart') 17 | const tiddlyWiki = require('./tiddlyWiki') 18 | 19 | require('events').prototype._maxListeners = 100 20 | 21 | process.chdir(path.resolve(__dirname, '..')) 22 | 23 | const router = express.Router() 24 | 25 | function serveIndex (req, res, next) { 26 | req.url = '/' 27 | next() 28 | } 29 | 30 | router.get('/', csp, serveIndex) 31 | router.get('/index.html', csp, serveIndex) 32 | router.get('/create', csp, serveIndex) 33 | router.get('/add-link', csp, serveIndex) 34 | router.all('/doc/:key/tw*', tiddlyWiki) 35 | router.get('/doc/:key', csp, serveIndex) 36 | 37 | const attachWebsocket = dbGateway(router) 38 | 39 | function runBudo () { 40 | const port = process.env.PORT || 5000 41 | const devServer = budo('index.js', { 42 | port, 43 | browserify: { 44 | transform: [ 45 | 'brfs', 46 | ['sheetify', {transform: ['sheetify-nested']}] 47 | ] 48 | }, 49 | middleware: [ 50 | hsts({maxAge: 10886400}), 51 | compression(), 52 | serviceWorkerNoCache, 53 | redirectToHttps, 54 | express.static('img'), 55 | router 56 | ], 57 | dir: ['.', 'static', '.data'], 58 | staticOptions: { 59 | cacheControl: true, 60 | maxAge: 60 * 60 * 1000 // one hour 61 | } 62 | /* 63 | stream: process.stdout, 64 | verbose: true 65 | */ 66 | }) 67 | devServer.on('connect', event => { 68 | console.log('Listening on', event.uri) 69 | attachWebsocket(event.server) 70 | periodicRestart(24 * 60) // Daily 71 | }) 72 | } 73 | 74 | mkdirp.sync('.data/img') 75 | 76 | makeServiceWorker(err => { 77 | checkError(err) 78 | makeImages(err => { 79 | checkError(err) 80 | makeTiddlyWikiPlugins(err => { 81 | checkError(err) 82 | runBudo() 83 | }) 84 | }) 85 | }) 86 | 87 | function checkError (err) { 88 | if (err) { 89 | console.error(err) 90 | throw err 91 | } 92 | } -------------------------------------------------------------------------------- /tiddlywiki/themes/snowwhite2/base.tid: -------------------------------------------------------------------------------- 1 | title: $:/themes/snowwhite2/base 2 | tags: [[$:/tags/Stylesheet]] 3 | 4 | \rules only filteredtranscludeinline transcludeinline macrodef macrocallinline 5 | 6 | .tc-sidebar-header { 7 | text-shadow: 0 1px 0 <>; 8 | } 9 | 10 | .tc-tiddler-info { 11 | <> 12 | } 13 | 14 | @media screen { 15 | .tc-tiddler-frame { 16 | <> 17 | } 18 | } 19 | 20 | @media (max-width: {{$:/themes/tiddlywiki/vanilla/metrics/sidebarbreakpoint}}) { 21 | .tc-tiddler-frame { 22 | <> 23 | } 24 | } 25 | 26 | .tc-page-controls button svg, .tc-tiddler-controls button svg, .tc-topbar button svg { 27 | <> 28 | } 29 | 30 | .tc-tiddler-controls button.tc-selected, 31 | .tc-page-controls button.tc-selected { 32 | <> 33 | } 34 | 35 | .tc-tiddler-frame input.tc-edit-texteditor { 36 | <> 37 | } 38 | 39 | .tc-edit-tags { 40 | <> 41 | } 42 | 43 | .tc-tiddler-frame .tc-edit-tags input.tc-edit-texteditor { 44 | <> 45 | border: none; 46 | outline: none; 47 | } 48 | 49 | textarea.tc-edit-texteditor { 50 | font-family: {{$:/themes/tiddlywiki/vanilla/settings/editorfontfamily}}; 51 | } 52 | 53 | canvas.tc-edit-bitmapeditor { 54 | <> 55 | } 56 | 57 | .tc-drop-down { 58 | border-radius: 4px; 59 | <> 60 | } 61 | 62 | .tc-block-dropdown { 63 | border-radius: 4px; 64 | <> 65 | } 66 | 67 | .tc-modal { 68 | border-radius: 6px; 69 | <> 70 | } 71 | 72 | .tc-modal-footer { 73 | border-radius: 0 0 6px 6px; 74 | <>; 75 | } 76 | 77 | 78 | .tc-alert { 79 | border-radius: 6px; 80 | <> 81 | } 82 | 83 | .tc-notification { 84 | border-radius: 6px; 85 | <> 86 | text-shadow: 0 1px 0 rgba(255,255,255, 0.8); 87 | } 88 | 89 | .tc-sidebar-lists .tc-tab-set .tc-tab-divider { 90 | border-top: none; 91 | height: 1px; 92 | <> 93 | } 94 | 95 | .tc-more-sidebar > .tc-tab-set > .tc-tab-buttons > button { 96 | <> 97 | } 98 | 99 | .tc-more-sidebar > .tc-tab-set > .tc-tab-buttons > button.tc-tab-selected { 100 | <> 101 | } 102 | 103 | .tc-message-box img { 104 | <> 105 | } 106 | 107 | .tc-plugin-info { 108 | <> 109 | } 110 | -------------------------------------------------------------------------------- /views/shoppingList.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const raw = require('choo/html/raw') 3 | const css = require('sheetify') 4 | const header = require('../components/header') 5 | const button = require('../components/button') 6 | const footer = require('../components/footer') 7 | const debugTools = require('../components/debugTools') 8 | const shoppingListTitle = require('../components/shoppingListTitle') 9 | const writeStatus = require('../components/writeStatus') 10 | const customAlert = require('../components/customAlert') 11 | 12 | const prefix = css` 13 | :host { 14 | .content { 15 | margin: 1rem 1rem 2rem 1rem; 16 | } 17 | 18 | .error { 19 | padding: 1rem; 20 | border: 2px solid red; 21 | border-radius: 1rem; 22 | text-align: center; 23 | margin: 1rem; 24 | } 25 | 26 | .tiddlyButton { 27 | display: flex; 28 | height: 8rem; 29 | align-items: center; 30 | justify-content: center; 31 | 32 | button { 33 | font-size: 1.2rem; 34 | padding: 1.3rem; 35 | } 36 | } 37 | 38 | .bottomNav { 39 | .delete { 40 | color: var(--color-red); 41 | text-decoration: none; 42 | float: right; 43 | } 44 | } 45 | } 46 | ` 47 | 48 | module.exports = shoppingListView 49 | 50 | function shoppingListView (state, emit) { 51 | emit('DOMTitleChange', 'Dat TiddlyWiki - ' + state.docTitle) 52 | 53 | function layout (inner) { 54 | return html` 55 | 56 | ${header(state)} 57 |
58 | ${inner} 59 | 63 |
64 | ${footer(state)} 65 | ${debugTools(state, emit)} 66 | ${customAlert.alertBox(state, emit)} 67 | 68 | ` 69 | } 70 | 71 | if (state.error) { 72 | return layout(html` 73 |
74 | ${state.error}
75 | (Try reloading, there occasionally are problems during sync) 76 |
77 | `) 78 | } 79 | if (state.loading) return layout('Loading...') 80 | 81 | return layout(html` 82 |
83 | ${shoppingListTitle(state, emit)} 84 | ${writeStatus(state, emit)} 85 |
86 | ${button.button('View TiddlyWiki', openTiddlyWiki)} 87 |
88 |
89 | `) 90 | 91 | function openTiddlyWiki (event) { 92 | const url = `/doc/${state.key.toString('hex')}/tw` 93 | location.href = url 94 | } 95 | 96 | function deleteList (event) { 97 | const confirm = window.confirm('Delete this list?') 98 | if (confirm) { 99 | emit('deleteCurrentDoc') 100 | } 101 | event.preventDefault() 102 | } 103 | 104 | function keydown (event) { 105 | if (event.key === ' ' || event.key === 'Enter') { 106 | event.target.click() 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /components/customAlert.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const css = require('sheetify') 4 | const focusTrap = require('focus-trap') 5 | const { button } = require('./button') 6 | 7 | const prefix = css` 8 | :host { 9 | position: absolute; 10 | top: 0; 11 | right: 0; 12 | bottom: 0; 13 | left: 0; 14 | background: rgba(0, 0, 0, 0.4); 15 | z-index: 1000; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | visibility: hidden; 20 | opacity: 0; 21 | transition: opacity 0.3s; 22 | 23 | &.show { 24 | visibility: visible; 25 | opacity: 1; 26 | 27 | .alertContent { 28 | transform: scale(1); 29 | opacity: 1; 30 | } 31 | } 32 | 33 | .alertContent { 34 | background: white; 35 | border: 1px solid black; 36 | padding: 3rem; 37 | margin: 3rem; 38 | font-size: 1.5rem; 39 | transform: scale(0.7); 40 | opacity: 0; 41 | transition: all 0.3s; 42 | border-radius: 0.5rem; 43 | 44 | button { 45 | margin: 2rem 0 0 0; 46 | width: 100%; 47 | font-size: 1.2rem; 48 | padding: 1rem; 49 | } 50 | } 51 | 52 | } 53 | ` 54 | 55 | module.exports = { 56 | alertBox, 57 | show 58 | } 59 | 60 | const local = { 61 | emit: null, 62 | display: false, 63 | message: '', 64 | cb: null 65 | } 66 | 67 | class Alert extends Component { 68 | constructor () { 69 | super() 70 | this.message = '' 71 | this.display = false 72 | this.close = this.close.bind(this) 73 | } 74 | 75 | createElement (message, display, cb) { 76 | this.message = message 77 | this.display = display 78 | this.cb = cb 79 | const show = display ? 'show' : '' 80 | if (this.focusTrap) { 81 | if (display) { 82 | setTimeout(this.focusTrap.activate, 100) // Let animation start 83 | } else { 84 | this.focusTrap.deactivate() 85 | } 86 | } 87 | return html` 88 |
89 |
90 |
91 | ${message} 92 |
93 | ${button('OK', this.close)} 94 |
95 |
96 | ` 97 | } 98 | 99 | load (el) { 100 | this.focusTrap = focusTrap(el) 101 | if (this.display) this.focusTrap.activate() 102 | } 103 | 104 | unload (el) { 105 | this.focusTrap = null 106 | } 107 | 108 | update (message, display, cb) { 109 | this.cb = cb 110 | return message !== this.message || display !== this.display 111 | } 112 | 113 | close (event) { 114 | event.stopPropagation() 115 | if (this.cb) this.cb() 116 | } 117 | } 118 | 119 | function alertBox (state, emit) { 120 | local.emit = emit 121 | return state.cache(Alert, 'alert').render(local.message, local.display, close) 122 | function close () { 123 | local.display = false 124 | emit('render') 125 | if (local.cb) local.cb() 126 | } 127 | } 128 | 129 | function show (message, cb) { 130 | Object.assign(local, {display: true, message, cb}) 131 | window.scroll({top: 0}) 132 | local.emit('render') 133 | } 134 | -------------------------------------------------------------------------------- /static/img/Motovun Jack.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2012-05-10 07:32ZCanvas 1Layer 1 4 | -------------------------------------------------------------------------------- /server/dbGateway.js: -------------------------------------------------------------------------------- 1 | const expressWebSocket = require('express-ws') 2 | const websocketStream = require('websocket-stream/stream') 3 | const ram = require('random-access-memory') 4 | // const hyperdrive = require('hyperdrive') 5 | const hyperdrive = require('@jimpick/hyperdrive-hyperdb-backend') 6 | const hyperdiscovery = require('hyperdiscovery') 7 | const pump = require('pump') 8 | const dumpWriters = require('../lib/dumpWriters') 9 | 10 | module.exports = dbGateway 11 | 12 | const maxArchives = 100 13 | const archives = {} 14 | 15 | setInterval(function cleanup () { 16 | const sortedArchives = Object.values(archives).sort((a, b) => a.lastAccess - b.lastAccess) 17 | console.log('Oldest to newest gatewayed archives:') 18 | sortedArchives.forEach((entry, index) => { 19 | const {archive, lastAccess, clients} = entry 20 | const key = archive.key && archive.key.toString('hex') 21 | const peers = archive.db.source.peers.length 22 | console.log(` ${index} ${lastAccess} ${key} (${clients} clients, ${peers} peers)`) 23 | }) 24 | if (sortedArchives.length > maxArchives) { 25 | for (let i = 0; i < sortedArchives.length - maxArchives; i++) { 26 | const archive = sortedArchives[i].archive 27 | const key = archive.key && archive.key.toString('hex') 28 | console.log(`Releasing ${i} ${key}`) 29 | sortedArchives[i].cancel() 30 | } 31 | } 32 | }, 60 * 1000) 33 | 34 | function dbGateway (router) { 35 | return function attachWebsocket (server) { 36 | console.log('Attaching websocket') 37 | expressWebSocket(router, server, { 38 | perMessageDeflate: false 39 | }) 40 | 41 | router.ws('/archive/:key', (ws, req) => { 42 | const archiveKey = req.params.key 43 | console.log('Websocket initiated for', archiveKey) 44 | let archive 45 | if (archives[archiveKey]) { 46 | archive = archives[archiveKey].archive 47 | archives[archiveKey].lastAccess = Date.now() 48 | } else { 49 | archive = hyperdrive(ram, archiveKey) 50 | archives[archiveKey] = { 51 | archive, 52 | lastAccess: Date.now(), 53 | cancel, 54 | clients: 0 55 | } 56 | archive.on('ready', () => { 57 | console.log('archive ready') 58 | // Join swarm 59 | const sw = hyperdiscovery(archive) 60 | archives[archiveKey].swarm = sw 61 | sw.on('connection', (peer, info) => { 62 | console.log('Swarm connection', info) 63 | }) 64 | const watcher = archive.db.watch(() => { 65 | console.log('Archive updated:', archive.key.toString('hex')) 66 | dumpWriters(archive) 67 | }) 68 | watcher.on('error', err => { 69 | console.error('Watcher error', err) 70 | }) 71 | }) 72 | } 73 | archive.ready(() => { 74 | archives[archiveKey].clients += 1 75 | const stream = websocketStream(ws) 76 | pump( 77 | stream, 78 | archive.replicate({encrypt: false, live: true}), 79 | stream, 80 | err => { 81 | console.log('pipe finished for ' + archiveKey, err && err.message) 82 | archives[archiveKey].clients -= 1 83 | } 84 | ) 85 | }) 86 | 87 | function cancel () { 88 | console.log(`Cancelling ${archiveKey}`) 89 | const sw = archives[archiveKey].swarm 90 | if (sw) sw.close() 91 | archive.db.source.peers.forEach(peer => peer.end()) 92 | delete archives[archiveKey] 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /components/statusDisplay.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const html = require('choo/html') 3 | const css = require('sheetify') 4 | const SvgIcon = require('./svgIcon') 5 | 6 | // Use browserify + brfs to convert these to inline strings 7 | const fs = require('fs') 8 | const syncIcon = fs.readFileSync( 9 | path.resolve(__dirname, '../static/img/ic_sync_black_24px.svg'), 10 | 'utf8' 11 | ) 12 | const syncIconDisabled = fs.readFileSync( 13 | path.resolve(__dirname, '../static/img/ic_sync_disabled_black_24px.svg'), 14 | 'utf8' 15 | ) 16 | const syncIconProblem = fs.readFileSync( 17 | path.resolve(__dirname, '../static/img/ic_sync_problem_black_24px.svg'), 18 | 'utf8' 19 | ) 20 | 21 | const prefix = css` 22 | :host { 23 | position: absolute; 24 | top: 0.4rem; 25 | right: 0.4rem; 26 | font-weight: 300; 27 | font-size: 0.8rem; 28 | 29 | @media only screen and (max-width : 350px) { 30 | font-size: 0.6rem; 31 | 32 | img { 33 | width: 0.7rem; 34 | height: 0.7rem; 35 | } 36 | } 37 | 38 | .online { 39 | color: var(--color-green); 40 | 41 | svg { 42 | fill: var(--color-green); 43 | } 44 | } 45 | 46 | .offline { 47 | color: var(--color-red); 48 | 49 | svg { 50 | fill: var(--color-red); 51 | } 52 | } 53 | 54 | .connecting { 55 | color: var(--color-yellow); 56 | 57 | svg { 58 | fill: var(--color-yellow); 59 | } 60 | } 61 | 62 | .online svg, 63 | .offline svg, 64 | .connecting svg { 65 | width: 1rem; 66 | height: 1rem; 67 | vertical-align: text-top; 68 | } 69 | 70 | img { 71 | width: 0.8rem; 72 | height: 0.8rem; 73 | } 74 | } 75 | ` 76 | 77 | module.exports = statusDisplay 78 | 79 | function statusDisplay (state) { 80 | if (!state) return null 81 | let networkStatus 82 | let connected 83 | let pendingUpload = state.localUploadLength - state.syncedUploadLength 84 | if (pendingUpload <= 0 || isNaN(pendingUpload)) pendingUpload = null 85 | if (pendingUpload) pendingUpload = html`${pendingUpload}↑` 86 | let pendingDownload = state.localDownloadLength - state.syncedDownloadLength 87 | if (pendingDownload <= 0 || isNaN(pendingDownload)) pendingDownload = null 88 | if (pendingDownload) pendingDownload = html`${pendingDownload}↓` 89 | if (state.networkStatus !== undefined) { 90 | const onlineOffline = state.networkStatus 91 | ? html`Online` 92 | : html`Offline` 93 | networkStatus = html` 94 |
95 | Network: ${onlineOffline} 96 |
97 | ` 98 | } 99 | if (state.connected !== undefined) { 100 | if (state.connecting) { 101 | connected = html` 102 | 103 | ${state.cache(SvgIcon, 'sync').render(syncIcon)} 104 | ${pendingDownload} 105 | ${pendingUpload} 106 | 107 | ` 108 | } else if (state.connected) { 109 | connected = html` 110 | 111 | ${state.cache(SvgIcon, 'sync').render(syncIcon)} 112 | ${pendingDownload} 113 | ${pendingUpload} 114 | 115 | ` 116 | } else { 117 | if (state.networkStatus) { 118 | connected = html` 119 | 120 | ${state.cache(SvgIcon, 'syncProblem').render(syncIconProblem)} 121 | ${pendingDownload} 122 | ${pendingUpload} 123 | 124 | ` 125 | } else { 126 | connected = html` 127 | 128 | ${state.cache(SvgIcon, 'syncDisabled').render(syncIconDisabled)} 129 | ${pendingDownload} 130 | ${pendingUpload} 131 | 132 | ` 133 | } 134 | } 135 | connected = html` 136 |
137 | Sync: ${connected} 138 |
139 | ` 140 | } 141 | let serviceWorker = state.serviceWorker ? html`
Worker Ready
` : null 142 | let devLabel = state.devMode ? html`
Label: ${state.devLabel}
` : null 143 | return html` 144 |
145 | ${networkStatus} 146 | ${connected} 147 | ${serviceWorker} 148 | ${devLabel} 149 |
150 | ` 151 | } 152 | -------------------------------------------------------------------------------- /components/githubButton.js: -------------------------------------------------------------------------------- 1 | const Component = require('choo/component') 2 | const html = require('choo/html') 3 | const raw = require('choo/html/raw') 4 | const css = require('sheetify') 5 | 6 | // CSS and HTML based on http://buttons.github.io/ 7 | 8 | const prefix = css` 9 | :host { 10 | margin: 0; 11 | font-size: 0; 12 | white-space: nowrap; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 14 | user-select: none; 15 | width: 100px; 16 | height: 28px; 17 | border: none; 18 | 19 | .btn { 20 | padding: 0 10px; 21 | font-size: 12px; 22 | background-color: #eff3f6; 23 | background-image: linear-gradient(to bottom, #fafbfc, #e4ebf0); 24 | background-repeat: repeat-x; 25 | background-size: 110% 110%; 26 | 27 | &:active { 28 | background-color: #e9ecef; 29 | background-image: none; 30 | border-color: #afb1b2; 31 | box-shadow: inset 0 0.15em 0.3em rgba(27,31,35,0.15); 32 | } 33 | 34 | &:hover { 35 | background-color: #e6ebf1; 36 | background-image: linear-gradient(to bottom, #f0f3f6, #dce3ec); 37 | border-color: #afb1b2; 38 | } 39 | 40 | span { 41 | vertical-align: 0; 42 | } 43 | } 44 | 45 | .social-count { 46 | padding: 0 7px; 47 | margin-left: 7px; 48 | font-size: 11px; 49 | position: relative; 50 | background-color: #fff; 51 | 52 | b { 53 | margin-right: 0; 54 | border-right-color: #d1d2d3 !important; 55 | } 56 | 57 | i { 58 | margin-right: -1.5px; 59 | border-right-color: #fff !important; 60 | } 61 | 62 | b, i { 63 | margin-top: -6px; 64 | position: absolute; 65 | top: 50%; 66 | right: 100%; 67 | display: block; 68 | width: 0; 69 | height: 0; 70 | border: 6px solid transparent; 71 | } 72 | 73 | span { 74 | vertical-align: 0; 75 | } 76 | 77 | &:hover { 78 | color: #0366d6; 79 | } 80 | } 81 | 82 | .btn, .social-count { 83 | height: 26px; 84 | line-height: 26px; 85 | display: inline-block; 86 | font-weight: 600; 87 | vertical-align: middle; 88 | cursor: pointer; 89 | border: 1px solid #d1d2d3; 90 | border-radius: 0.25em; 91 | 92 | &:focus { 93 | border-color: #c8e1ff; 94 | } 95 | } 96 | 97 | a { 98 | color: #24292e; 99 | text-decoration: none; 100 | outline: 0; 101 | } 102 | 103 | .octicon { 104 | height: 16px; 105 | top: 4px; 106 | position: relative; 107 | display: inline-block; 108 | fill: currentColor; 109 | } 110 | } 111 | ` 112 | 113 | class GitHubButton extends Component { 114 | constructor (id, state, emit, repo) { 115 | super() 116 | this.loaded = false 117 | this.repo = repo 118 | this.stargazersCount = null 119 | } 120 | 121 | createElement () { 122 | const {repo} = this 123 | const svg = raw(` 124 | 127 | `) 128 | return html` 129 | 138 | ` 139 | } 140 | 141 | load (el) { 142 | const url = `https://api.github.com/repos/${this.repo}` 143 | window.fetch(url) 144 | .then(res => res.json()) 145 | .then(({stargazers_count: stargazersCount}) => { 146 | this.stargazersCount = stargazersCount 147 | const linkEl = this.stargazersLink() 148 | el.appendChild(linkEl) 149 | }) 150 | } 151 | 152 | stargazersLink () { 153 | const {repo, stargazersCount} = this 154 | return html` 155 | 161 | ` 162 | } 163 | 164 | update () { 165 | return false 166 | } 167 | } 168 | 169 | module.exports = GitHubButton 170 | -------------------------------------------------------------------------------- /lib/websocketGateway.js: -------------------------------------------------------------------------------- 1 | const websocket = require('websocket-stream') 2 | const pump = require('pump') 3 | const equal = require('deep-equal') 4 | const dumpWriters = require('./dumpWriters') 5 | 6 | module.exports = connectToGateway 7 | 8 | let replicationCount = 0 9 | let connecting = 0 10 | 11 | function connectToGateway (archive, updateSyncStatus, updateConnecting) { 12 | const key = archive.key.toString('hex') 13 | const host = document.location.host 14 | const proto = document.location.protocol === 'https:' ? 'wss' : 'ws' 15 | const url = `${proto}://${host}/archive/${key}` 16 | console.log('connectToGateway', key) 17 | 18 | let cancelled = false 19 | let connected = false 20 | 21 | const intervalId = setInterval(dumpWritersIfChanged, 1000) 22 | 23 | let archiveStream 24 | function connectWebsocket () { 25 | if (cancelled) return 26 | if (connected) return 27 | if (navigator.onLine === false) { 28 | console.log('Offline, not syncing') 29 | console.log('Waiting 5 seconds to reconnect') 30 | setTimeout(connectWebsocket, 5000) 31 | return 32 | } 33 | console.log('Connecting websocket', url) 34 | console.log('Active replications', ++replicationCount) 35 | const stream = websocket(url) 36 | archiveStream = archive.replicate({encrypt: false, live: true}) 37 | updateConnecting && updateConnecting(++connecting) 38 | connected = true 39 | window.addEventListener('offline', goOfflineNow) 40 | window.removeEventListener('online', goOnlineNow) 41 | pump( 42 | stream, 43 | archiveStream, 44 | stream, 45 | err => { 46 | connected = false 47 | updateConnecting && updateConnecting(--connecting) 48 | if (err) { 49 | console.log('Pipe finished', err.message) 50 | if (err.stack) { 51 | console.log(err.stack) 52 | } 53 | } else { 54 | console.log('Pipe finished, no errors') 55 | } 56 | console.log('Active replications', --replicationCount) 57 | window.removeEventListener('offline', goOfflineNow) 58 | if (!cancelled) { 59 | console.log('Waiting 5 seconds to reconnect') 60 | setTimeout(connectWebsocket, 5000) 61 | window.addEventListener('online', goOnlineNow) 62 | } else { 63 | dumpWritersIfChanged() 64 | clearInterval(intervalId) 65 | } 66 | } 67 | ) 68 | 69 | function goOfflineNow () { 70 | if (connected && !archiveStream.destroyed) { 71 | console.log('Browser went offline - Ending replication on websocket') 72 | archiveStream.finalize() // Gracefully end the stream 73 | } 74 | } 75 | 76 | function goOnlineNow () { 77 | if (!connected && archiveStream.destroyed) { 78 | console.log('Browser went online - Restarting replication on websocket') 79 | connectWebsocket() 80 | } 81 | } 82 | } 83 | connectWebsocket() 84 | 85 | let lastUpdate 86 | 87 | function dumpWritersIfChanged () { 88 | const db = archive.db 89 | const update = { 90 | connectedPeers: db.source.peers.filter(peer => !!peer.remoteId).length, 91 | writers: db._writers.map( 92 | writer => writer && [writer._feed.length, writer._feed.remoteLength] 93 | ), 94 | contentFeeds: db.contentFeeds.map( 95 | feed => feed && [feed.length, feed.remoteLength] 96 | ) 97 | } 98 | if (!equal(update, lastUpdate)) { 99 | dumpWriters(archive) 100 | lastUpdate = update 101 | } 102 | const {connectedPeers} = update 103 | const syncStatus = update.writers.reduce( 104 | (acc, pair, index) => { 105 | const newAcc = Object.assign({}, acc) 106 | const contentPair = update.contentFeeds[index] 107 | if (index === db._localWriter._id) { 108 | if (pair) { 109 | newAcc.localUploadLength += pair[0] 110 | newAcc.remoteUploadLength += pair[1] 111 | } 112 | if (contentPair) { 113 | newAcc.localUploadLength += contentPair[0] 114 | newAcc.remoteUploadLength += contentPair[1] 115 | } 116 | } else { 117 | if (pair) { 118 | newAcc.localDownloadLength += pair[0] 119 | newAcc.remoteDownloadLength += pair[1] 120 | } 121 | if (contentPair) { 122 | newAcc.localDownloadLength += contentPair[0] 123 | newAcc.remoteDownloadLength += contentPair[1] 124 | } 125 | } 126 | return newAcc 127 | }, 128 | { 129 | key, 130 | connectedPeers, 131 | localUploadLength: 0, 132 | remoteUploadLength: 0, 133 | localDownloadLength: 0, 134 | remoteDownloadLength: 0 135 | } 136 | ) 137 | updateSyncStatus && updateSyncStatus(syncStatus) 138 | } 139 | 140 | function cancel () { 141 | cancelled = true 142 | console.log('Ending replication on websocket') 143 | if (!connected || !archiveStream) { 144 | clearInterval(intervalId) 145 | } 146 | if (archiveStream) { 147 | archiveStream.finalize() // Gracefully end the stream 148 | } 149 | } 150 | 151 | return cancel 152 | } 153 | -------------------------------------------------------------------------------- /views/main.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const css = require('sheetify') 3 | const prettyHash = require('pretty-hash') 4 | const header = require('../components/header') 5 | const button = require('../components/button') 6 | const footer = require('../components/footer') 7 | 8 | const prefix = css` 9 | :host { 10 | .content { 11 | margin: 1rem 1rem 2rem 1rem; 12 | } 13 | .uvp { 14 | box-shadow: 0 0 20px rgba(0,0,0,.15); 15 | padding: 1em; 16 | background-color: var(--color-white); 17 | } 18 | .uvp h4 { 19 | margin: 0.5rem 1rem 1rem 1rem; 20 | font-size: 1.3rem; 21 | text-align: center; 22 | } 23 | h3 { 24 | margin-top: 2rem; 25 | } 26 | ul { 27 | padding: 0 0.3rem 0.5rem 0.3rem; 28 | } 29 | li { 30 | list-style-type: none; 31 | border: 1px solid var(--color-neutral-20); 32 | border-radius: 0.5rem; 33 | margin: 0 0 0.5rem 0; 34 | padding: 0 2rem 0 0.5rem; 35 | min-height: 3rem; 36 | position: relative; 37 | cursor: pointer; 38 | font-size: 1.2rem; 39 | background-color: var(--color-white); 40 | display: flex; 41 | align-items: center; 42 | 43 | .key { 44 | font-size: 12px; 45 | font-family: monospace; 46 | line-height: 1rem; 47 | position: absolute; 48 | top: 0.1rem; 49 | right: 0.3rem; 50 | pointer-events: none; 51 | } 52 | 53 | .settings { 54 | position: absolute; 55 | bottom: 0.3rem; 56 | right: 0.3rem; 57 | } 58 | } 59 | .solo { 60 | background-image: url(/img/bg-landing-page.svg); 61 | background-position: center; 62 | background-repeat: no-repeat; 63 | height: 16rem; 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | flex-direction: column; 68 | 69 | button { 70 | height: 4rem; 71 | } 72 | 73 | .addLinkButton button { 74 | margin-top: 1.5rem; 75 | height: 2.5rem; 76 | font-size: 0.8rem; 77 | font-weight: 500; 78 | } 79 | 80 | } 81 | 82 | .notSolo { 83 | display: flex; 84 | justify-content: space-between; 85 | margin: 0 0.5rem; 86 | 87 | .createButton { 88 | margin-right: 0.5rem; 89 | } 90 | 91 | .addLinkButton { 92 | margin-left: 0.5rem; 93 | } 94 | } 95 | 96 | .addLinkButton button { 97 | color: var(--color-green); 98 | background: var(--color-white); 99 | border-color: var(--color-green); 100 | } 101 | } 102 | ` 103 | 104 | module.exports = mainView 105 | 106 | function mainView (state, emit) { 107 | emit('DOMTitleChange', 'Dat TiddlyWiki') 108 | const documents = state.documents.map(doc => { 109 | return html` 110 |
  • 111 | ${doc.name} 112 | ${prettyHash(doc.key)} 113 | 114 | 115 | 116 | 117 | 118 |
  • 119 | ` 120 | 121 | function openTiddlyWiki (event) { 122 | const url = `/doc/${doc.key}/tw` 123 | location.href = url 124 | event.preventDefault() 125 | } 126 | 127 | function click (event) { 128 | const link = event.target.querySelector('a') 129 | if (link) link.click() 130 | event.stopPropagation() 131 | } 132 | 133 | function keydown (event) { 134 | if (event.key === ' ' || event.key === 'Enter') { 135 | event.target.click() 136 | } 137 | } 138 | }) 139 | const docHeader = documents.length > 0 ? html`

    TiddlyWikis

    ` : null 140 | const soloCta = documents.length === 0 ? 'solo' : 'notSolo' 141 | return html` 142 | 143 | ${header(state)} 144 |
    145 |
    146 |

    Multiwriter Peer-to-Peer TiddlyWikis!

    147 |

    148 | This is a Progressive Web App built to demonstrate the use of the new 149 | multi-writer capabilities from the 150 | Dat Project. 151 |

    152 |

    153 | Make wikis and use them online or offline, and sync between multiple 154 | devices or users. 155 |

    156 |

    157 |
    158 | ${docHeader} 159 |
    160 |
      161 | ${documents} 162 |
    163 |
    164 |
    165 | ${button.button('Create a new TiddlyWiki', () => emit('pushState', '/create'))} 166 |
    167 |
    168 | ${button.button('Have a Link? Paste it Here', () => emit('pushState', '/add-link'))} 169 |
    170 |
    171 |
    172 | ${footer(state)} 173 | 174 | ` 175 | } 176 | -------------------------------------------------------------------------------- /stores/documents.js: -------------------------------------------------------------------------------- 1 | const thunky = require('thunky') 2 | 3 | module.exports = store 4 | 5 | const documentsDbName = 'tiddlywikis' 6 | 7 | function store (state, emitter) { 8 | state.documents = [] 9 | 10 | const ready = thunky(openDocumentsDB) 11 | 12 | ready(() => { emitter.emit('render') }) 13 | 14 | emitter.on('writeNewDocumentRecord', (keyHex, docName) => { 15 | ready(() => { 16 | if (state.documents.find(doc => doc.key === keyHex)) return 17 | writeDocumentRecord(keyHex, docName, err => { 18 | if (err) throw err 19 | emitter.emit('pushState', `/doc/${keyHex}`) 20 | }) 21 | }) 22 | }) 23 | 24 | emitter.on('deleteCurrentDoc', () => { 25 | const keyHex = state.params.key 26 | deleteDoc(keyHex, err => { 27 | if (err) throw err 28 | console.log('Doc deleted', keyHex) 29 | emitter.emit('pushState', '/') 30 | }) 31 | }) 32 | 33 | emitter.on('fetchDocLastSync', fetchDocLastSync) 34 | emitter.on('updateDocLastSync', updateDocLastSync) 35 | 36 | // Store documents in indexedDB 37 | function openDocumentsDB (cb) { 38 | const request = window.indexedDB.open(documentsDbName, 1) 39 | request.onerror = function (event) { 40 | console.log('IndexedDB error') 41 | } 42 | request.onsuccess = function (event) { 43 | state.documentsDB = event.target.result 44 | readDocuments(cb) 45 | } 46 | request.onupgradeneeded = function (event) { 47 | const db = event.target.result 48 | let objectStore 49 | if (event.oldVersion === 0) { 50 | objectStore = db.createObjectStore('documents', {keyPath: 'key'}) 51 | objectStore.createIndex('name', 'name') 52 | objectStore.createIndex('dateAdded', 'dateAdded') 53 | } 54 | objectStore.transaction.oncomplete = function (event) { 55 | console.log('Document db created') 56 | } 57 | } 58 | } 59 | 60 | function readDocuments (cb) { 61 | const db = state.documentsDB 62 | if (!db) return 63 | const objectStore = db.transaction('documents').objectStore('documents') 64 | const index = objectStore.index('dateAdded') 65 | state.documents = [] 66 | index.openCursor().onsuccess = function (event) { 67 | const cursor = event.target.result 68 | if (cursor) { 69 | state.documents.push(cursor.value) 70 | cursor.continue() 71 | } else { 72 | cb() 73 | } 74 | } 75 | } 76 | 77 | function writeDocumentRecord (key, name, cb) { 78 | const db = state.documentsDB 79 | if (!db) return 80 | const request = db.transaction('documents', 'readwrite') 81 | .objectStore('documents') 82 | .add({ 83 | key, 84 | name, 85 | dateAdded: Date.now(), 86 | lastSync: null, 87 | syncedUploadLength: 0, 88 | syncedDownloadLength: 0 89 | }) 90 | request.onsuccess = function (event) { 91 | readDocuments(() => { 92 | console.log('documents reloaded') 93 | cb() 94 | }) 95 | } 96 | request.onerror = function (err) { 97 | cb(err) 98 | } 99 | } 100 | 101 | function deleteDoc (key, cb) { 102 | const db = state.documentsDB 103 | const request = db.transaction('documents', 'readwrite') 104 | .objectStore('documents') 105 | .delete(key) 106 | request.onsuccess = function (event) { 107 | // Note: Deleting db doesn't return success ... probably because it's 108 | // still in use? It appears that it still gets cleaned up. 109 | window.indexedDB.deleteDatabase(`doc-${key}`) 110 | readDocuments(() => { 111 | console.log('documents reloaded') 112 | cb() 113 | }) 114 | } 115 | request.onerror = function (err) { 116 | cb(err) 117 | } 118 | } 119 | 120 | function fetchDocLastSync (key) { 121 | state.lastSync = null 122 | state.syncedUploadLength = null 123 | state.syncedDownloadLength = null 124 | state.localUploadLength = null 125 | state.localDownloadLength = null 126 | ready(() => { 127 | const db = state.documentsDB 128 | const objectStore = db.transaction('documents', 'readwrite') 129 | .objectStore('documents') 130 | const request = objectStore.get(key) 131 | request.onsuccess = function (event) { 132 | const data = event.target.result 133 | if (!data) return 134 | state.lastSync = data.lastSync 135 | state.syncedUploadLength = data.syncedUploadLength 136 | state.syncedDownloadLength = data.syncedDownloadLength 137 | } 138 | request.onerror = function (event) { 139 | console.error('fetchDocLastSync error', event) 140 | } 141 | }) 142 | } 143 | 144 | function updateDocLastSync ({key, syncedUploadLength, syncedDownloadLength}) { 145 | ready(() => { 146 | const db = state.documentsDB 147 | const objectStore = db.transaction('documents', 'readwrite') 148 | .objectStore('documents') 149 | const request = objectStore.get(key) 150 | request.onsuccess = function (event) { 151 | const data = event.target.result 152 | if (!data) return 153 | data.syncedUploadLength = syncedUploadLength 154 | data.syncedDownloadLength = syncedDownloadLength 155 | data.lastSync = Date.now() 156 | const requestUpdate = objectStore.put(data) 157 | requestUpdate.onerror = function (event) { 158 | console.error('updateDocLastSync update error', event) 159 | } 160 | } 161 | request.onerror = function (event) { 162 | console.error('updateDocLastSync error', event) 163 | } 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of dat-shopping-list is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in dat-shopping-list to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open Source Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people’s personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone’s consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Consequences of Unacceptable Behavior 47 | 48 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 49 | 50 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 51 | 52 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 53 | 54 | ## 6. Reporting Guidelines 55 | 56 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. jim@jimpick.com. 57 | 58 | [LINK_TO_REPORTING_GUIDELINES] 59 | 60 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 61 | 62 | ## 7. Addressing Grievances 63 | 64 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify jim@jimpick.com with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 65 | 66 | [LINK_TO_POLICY] 67 | 68 | ## 8. Scope 69 | 70 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. 71 | 72 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 73 | 74 | ## 9. Contact info 75 | 76 | jim@jimpick.com 77 | 78 | ## 10. License and attribution 79 | 80 | This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 81 | 82 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 83 | 84 | Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) 85 | -------------------------------------------------------------------------------- /components/writeStatus.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const raw = require('choo/html/raw') 3 | const css = require('sheetify') 4 | const copy = require('clipboard-copy') 5 | const customAlert = require('./customAlert') 6 | const button = require('./button') 7 | 8 | module.exports = writeStatus 9 | 10 | const prefix = css` 11 | :host { 12 | box-shadow: 0 0 10px rgba(0,0,0,.15); 13 | padding: 0.7rem; 14 | position: relative; 15 | -webkit-tap-highlight-color: transparent; 16 | 17 | .collapseExpand { 18 | position: absolute; 19 | top: -0.8rem; 20 | right: 0.6rem; 21 | z-index: 1; 22 | font-size: 0.8rem; 23 | cursor: pointer; 24 | color: var(--color-green); 25 | background: var(--color-white); 26 | border: 2px solid var(--color-neutral-10); 27 | border-radius: 0.8rem; 28 | width: 5rem; 29 | height: 1.4rem; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | } 34 | 35 | .noAuth { 36 | color: var(--color-red); 37 | font-weight: 700; 38 | } 39 | 40 | .okAuth { 41 | color: var(--color-green); 42 | font-weight: 700; 43 | } 44 | 45 | .help { 46 | font-size: 0.8rem; 47 | font-weight: 500; 48 | margin-left: 0.5rem; 49 | margin-right: 0.5rem; 50 | } 51 | 52 | .localKeySection { 53 | -webkit-tap-highlight-color: black; 54 | background: var(--color-neutral-10); 55 | padding: 0.5rem; 56 | 57 | .noWrap { 58 | white-space: nowrap; 59 | display: flex; 60 | 61 | .localKey { 62 | color: var(--color-blue-darker); 63 | text-overflow: ellipsis; 64 | overflow: hidden; 65 | } 66 | } 67 | 68 | @media only screen and (min-device-width : 500px) and (max-device-width : 600px) { 69 | .localKey { 70 | font-size: 12px; 71 | } 72 | } 73 | 74 | @media only screen and (min-device-width : 400px) and (max-device-width : 500px) { 75 | .localKey { 76 | font-size: 10px; 77 | } 78 | } 79 | 80 | @media only screen and (max-width : 400px) { 81 | .localKey { 82 | font-size: 8px; 83 | } 84 | } 85 | 86 | button { 87 | font-size: 0.7rem; 88 | padding: 0.5rem 0.5rem; 89 | font-weight: 400; 90 | margin-right: 1rem; 91 | } 92 | } 93 | 94 | form { 95 | margin: 0; 96 | 97 | .writerInputs { 98 | display: flex; 99 | flex-wrap: wrap; 100 | align-items: center; 101 | font-size: 16px; 102 | 103 | div { 104 | margin-right: 0.4rem; 105 | } 106 | 107 | input[type="text"] { 108 | font-size: 16px; 109 | flex: 1; 110 | margin-right: 0.4rem; 111 | } 112 | 113 | input[type="submit"] { 114 | font-size: 16px; 115 | padding: 0.1rem 0.5rem; 116 | font-weight: 400; 117 | } 118 | } 119 | } 120 | } 121 | ` 122 | 123 | function writeStatus (state, emit) { 124 | const db = state.archive && state.archive.db 125 | if (!db) return null 126 | const localKey = db.local.key.toString('hex') 127 | let sourceCopy = null 128 | if (!state.writeStatusCollapsed) { 129 | sourceCopy = db.local === db.source 130 | ? 'You created this document.' 131 | : 'You joined this document.' 132 | } 133 | let authStatus = null 134 | if (state.authorized) { 135 | if (state.writeStatusCollapsed) { 136 | authStatus = html`
    Authorized (Expand to add a writer)
    ` 137 | } else { 138 | authStatus = html`
    You are authorized to write to this document.
    ` 139 | } 140 | } else { 141 | let explanationAndLocalKey = null 142 | if (!state.writeStatusCollapsed) { 143 | explanationAndLocalKey = html` 144 |
    145 |

    146 | You may edit your local copy, but changes will not be synchronized until you 147 | pass your "local key" to an owner of the document and they authorize you. 148 |

    149 |
    e.stopPropagation()}> 150 | Your local key is: 151 |
    152 | ${localKey} 153 |
    154 | ${button.button('Copy to Clipboard', copyToClipboard)} 155 | ${state.localKeyCopied ? 'Copied!' : null} 156 |
    157 |
    158 | ` 159 | } 160 | let noAuth 161 | if (!state.writeStatusCollapsed) { 162 | noAuth = html`
    163 | You are not currently authorized to write to this document. 164 |
    ` 165 | } else { 166 | noAuth = html`
    167 | Not authorized 168 | (Expand for more info) 169 |
    ` 170 | } 171 | authStatus = html`
    172 | ${noAuth} 173 | ${explanationAndLocalKey} 174 |
    ` 175 | } 176 | let authForm = null 177 | if (!state.writeStatusCollapsed && state.authorized) { 178 | const localKeyInput = html` 179 | 180 | ` 181 | localKeyInput.isSameNode = function (target) { 182 | return (target && target.nodeName && target.nodeName === 'INPUT') 183 | } 184 | authForm = html` 185 |
    186 |

    187 | You can share this shopping list to multiple devices or other 188 | people. Just copy the URL and paste it into another browser. 189 | (Hint: You can click 190 | on the "hex number" on the upper right to copy the URL to your 191 | clipboard). Other copies may write to this document if you 192 | authorize them by pasting their 'local key' into the form below. 193 |

    194 |
    e.stopPropagation()}> 195 |
    Add a writer:
    196 | ${localKeyInput} 197 | ${button.submit('Authorize')} 198 |
    199 |
    200 | ` 201 | } 202 | const collapseExpand = state.writeStatusCollapsed 203 | ? raw('▼ Expand') : raw('▲ Collapse') 204 | return html` 205 |
    emit('toggleWriteStatusCollapsed')}> 206 |
    207 | ${collapseExpand} 208 |
    209 |
    ${sourceCopy}
    210 | ${authStatus} 211 | ${authForm} 212 |
    213 | ` 214 | 215 | function copyToClipboard () { 216 | copy(localKey).then(() => { 217 | customAlert.show('"Local Key" copied to clipboard') 218 | state.localKeyCopied = true 219 | emit('render') 220 | }) 221 | } 222 | 223 | function submit (event) { 224 | const input = event.target.querySelector('input') 225 | const writerKey = input.value.trim() 226 | if (writerKey !== '') { 227 | emit('authorize', writerKey) 228 | input.value = '' 229 | } 230 | event.preventDefault() 231 | } 232 | } 233 | 234 | function keydown (event) { 235 | if (event.key === ' ' || event.key === 'Enter') { 236 | event.target.click() 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /stores/shoppingList.js: -------------------------------------------------------------------------------- 1 | const rai = require('random-access-idb') 2 | const toBuffer = require('to-buffer') 3 | // const hyperdrive = require('hyperdrive') 4 | const hyperdrive = require('@jimpick/hyperdrive-hyperdb-backend') 5 | const crypto = require('hypercore/lib/crypto') 6 | const newId = require('monotonic-timestamp-base36') 7 | const dumpWriters = require('../lib/dumpWriters') 8 | const downloadZip = require('../lib/downloadZip') 9 | const connectToGateway = require('../lib/websocketGateway') 10 | const customAlert = require('../components/customAlert') 11 | 12 | require('events').prototype._maxListeners = 100 13 | 14 | module.exports = store 15 | 16 | function store (state, emitter) { 17 | state.shoppingList = [] 18 | state.localKeyCopied = false 19 | state.writeStatusCollapsed = window.localStorage.getItem( 20 | 'writeStatusCollapsed' 21 | ) 22 | 23 | emitter.on('DOMContentLoaded', updateDoc) 24 | emitter.on('navigate', updateDoc) 25 | 26 | emitter.on('addLink', link => { 27 | const match = link.match(/([0-9a-fA-F]{64})\/?(tw)?\/?$/) 28 | if (match) { 29 | const key = match[1] 30 | emitter.emit('pushState', `/doc/${key}`) 31 | } else { 32 | customAlert.show('URL or key must contain a 64 character hex value', () => { 33 | const textInput = document.querySelector('.content input[type="text"]') 34 | textInput.removeAttribute('disabled') 35 | const submitButton = document.querySelector('.content input[type="submit"]') 36 | submitButton.removeAttribute('disabled') 37 | }) 38 | } 39 | }) 40 | 41 | function updateDoc () { 42 | emitter.once('render', () => { 43 | document.body.scrollIntoView(true) 44 | // Do it again for mobile Safari 45 | setTimeout(() => document.body.scrollIntoView(true), 200) 46 | }) 47 | state.error = null 48 | state.authorized = null 49 | state.shoppingList = [] 50 | state.localKeyCopied = false 51 | state.docTitle = '' 52 | if (!state.params || !state.params.key) { 53 | state.archive = null 54 | state.key = null 55 | state.loading = false 56 | emitter.emit('render') 57 | } else { 58 | const keyHex = state.params.key 59 | console.log(`Loading ${keyHex}`) 60 | state.localFeedLength = null 61 | emitter.emit('fetchDocLastSync', keyHex) 62 | const storage = rai(`doc-${keyHex}`) 63 | const archive = hyperdrive(storage, keyHex) 64 | state.loading = true 65 | emitter.emit('render') 66 | archive.ready(() => { 67 | console.log('hyperdrive ready') 68 | console.log('Local key:', archive.db.local.key.toString('hex')) 69 | dumpWriters(archive) 70 | state.archive = archive 71 | state.key = archive.key 72 | if (state.cancelGatewayReplication) state.cancelGatewayReplication() 73 | state.cancelGatewayReplication = connectToGateway( 74 | archive, updateSyncStatus, updateConnecting 75 | ) 76 | if (archive.db._writers[0].length() > 0) { 77 | readShoppingList() 78 | } 79 | archive.db.watch(() => { 80 | console.log('Archive updated:', archive.key.toString('hex')) 81 | dumpWriters(archive) 82 | readShoppingList() 83 | }) 84 | }) 85 | } 86 | } 87 | 88 | emitter.on('createDoc', docName => { 89 | const {publicKey: key, secretKey} = crypto.keyPair() 90 | const keyHex = key.toString('hex') 91 | console.log('Create doc:', docName, keyHex) 92 | const storage = rai(`doc-${keyHex}`) 93 | const archive = hyperdrive(storage, key, {secretKey}) 94 | archive.ready(() => { 95 | console.log('hyperdrive ready') 96 | state.key = key 97 | state.archive = archive 98 | let shoppingList = ['Rice', 'Bananas', 'Kale', 'Avocados', 'Bread', 'Quinoa', 'Beer'] 99 | writeDatJson(() => { 100 | writeShoppingListItems(() => { 101 | console.log('Done') 102 | emitter.emit('writeNewDocumentRecord', keyHex, docName) 103 | }) 104 | }) 105 | 106 | function writeDatJson (cb) { 107 | const json = JSON.stringify({ 108 | url: `dat://${keyHex}/`, 109 | title: docName, 110 | description: `Dat Shopping List demo - https://${state.glitchAppName}.glitch.me/` 111 | }, null, 2) 112 | archive.writeFile('dat.json', json, err => { 113 | if (err) throw err 114 | cb() 115 | }) 116 | } 117 | 118 | function writeShoppingListItems (cb) { 119 | const item = shoppingList.shift() 120 | if (!item) return cb() 121 | const json = JSON.stringify({ 122 | name: item, 123 | bought: false, 124 | dateAdded: Date.now() 125 | }) 126 | archive.writeFile(`/shopping-list/${newId()}.json`, json, err => { 127 | if (err) throw err 128 | writeShoppingListItems(cb) 129 | }) 130 | } 131 | }) 132 | }) 133 | 134 | function updateSyncStatus (message) { 135 | const { 136 | key, 137 | connectedPeers, 138 | localUploadLength, 139 | remoteUploadLength, 140 | localDownloadLength, 141 | remoteDownloadLength 142 | } = message 143 | if (state.key && key !== state.key.toString('hex')) return 144 | state.connected = !!connectedPeers 145 | state.localUploadLength = state.loading ? null : localUploadLength 146 | state.localDownloadLength = state.loading ? null : localDownloadLength 147 | if (state.key && connectedPeers) { 148 | state.connecting = false 149 | state.syncedUploadLength = remoteUploadLength 150 | state.syncedDownloadLength = remoteDownloadLength 151 | emitter.emit( 152 | 'updateDocLastSync', 153 | { 154 | key, 155 | syncedUploadLength: remoteUploadLength, 156 | syncedDownloadLength: remoteDownloadLength 157 | } 158 | ) 159 | } 160 | emitter.emit('render') 161 | } 162 | 163 | function updateConnecting (connecting) { 164 | state.connecting = connecting 165 | } 166 | 167 | function readShoppingList () { 168 | const archive = state.archive 169 | const shoppingList = [] 170 | archive.readdir('/shopping-list', (err, fileList) => { 171 | if (err) { 172 | console.log('Error', err) 173 | state.error = 'Error loading shopping list' 174 | emitter.emit('render') 175 | return 176 | } 177 | console.log('Shopping list files:', fileList.length) 178 | readTitleFromDatJson((err, title) => { 179 | if (err) { 180 | console.log('Error', err) 181 | state.error = 'Error loading shopping list' 182 | emitter.emit('render') 183 | return 184 | } 185 | readShoppingListFiles(err => { 186 | if (err) { 187 | console.log('Error', err) 188 | state.error = 'Error loading shopping list' 189 | emitter.emit('render') 190 | return 191 | } 192 | console.log('Done reading files.', title) 193 | updateAuthorized(err => { 194 | if (err) throw err 195 | state.loading = false 196 | state.docTitle = title 197 | state.shoppingList = shoppingList 198 | emitter.emit('writeNewDocumentRecord', state.params.key, title) 199 | emitter.emit('render') 200 | }) 201 | }) 202 | }) 203 | 204 | function readTitleFromDatJson (cb) { 205 | archive.readFile('dat.json', 'utf8', (err, contents) => { 206 | if (err) { 207 | console.error('dat.json error', err) 208 | return cb(null, 'Unknown') 209 | } 210 | if (!contents) return cb(null, 'Unknown') 211 | try { 212 | const metadata = JSON.parse(contents) 213 | cb(null, metadata.title) 214 | } catch (e) { 215 | console.error('Parse error', e) 216 | cb(null, 'Unknown') 217 | } 218 | }) 219 | } 220 | 221 | function readShoppingListFiles (cb) { 222 | const file = fileList.shift() 223 | if (!file) return cb() 224 | archive.readFile(`/shopping-list/${file}`, 'utf8', (err, contents) => { 225 | if (err) return cb(err) 226 | try { 227 | const item = JSON.parse(contents) 228 | item.file = file 229 | shoppingList.push(item) 230 | } catch (e) { 231 | console.error('Parse error', e) 232 | } 233 | readShoppingListFiles(cb) 234 | }) 235 | } 236 | }) 237 | } 238 | 239 | function updateAuthorized (cb) { 240 | if (state.authorized === true) return cb() 241 | const db = state.archive.db 242 | console.log('Checking if local key is authorized') 243 | db.authorized(db.local.key, (err, authorized) => { 244 | if (err) return cb(err) 245 | console.log('Authorized status:', authorized) 246 | if ( 247 | state.authorized === false && 248 | authorized === true && 249 | !state.writeStatusCollapsed 250 | ) { 251 | emitter.emit('toggleWriteStatusCollapsed') 252 | } 253 | state.authorized = authorized 254 | cb() 255 | }) 256 | } 257 | 258 | emitter.on('toggleBought', itemFile => { 259 | const item = state.shoppingList.find(item => item.file === itemFile) 260 | console.log('toggleBought', itemFile, item) 261 | // item.bought = !item.bought 262 | const archive = state.archive 263 | const json = JSON.stringify({ 264 | name: item.name, 265 | bought: !item.bought, 266 | dateAdded: item.dateAdded 267 | }) 268 | archive.writeFile(`/shopping-list/${item.file}`, json, err => { 269 | if (err) throw err 270 | console.log(`Rewrote: ${item.file}`) 271 | }) 272 | }) 273 | 274 | emitter.on('remove', itemFile => { 275 | const item = state.shoppingList.find(item => item.file === itemFile) 276 | console.log('remove', itemFile, item) 277 | // item.bought = !item.bought 278 | const archive = state.archive 279 | archive.unlink(`/shopping-list/${item.file}`, err => { 280 | if (err) throw err 281 | console.log(`Unlinked: ${item.file}`) 282 | }) 283 | }) 284 | 285 | emitter.on('addItem', name => { 286 | console.log('addItem', name) 287 | const archive = state.archive 288 | const json = JSON.stringify({ 289 | name, 290 | bought: false, 291 | dateAdded: Date.now() 292 | }) 293 | const file = newId() + '.json' 294 | archive.writeFile(`/shopping-list/${file}`, json, err => { 295 | if (err) throw err 296 | console.log(`Created: ${file}`) 297 | }) 298 | }) 299 | 300 | emitter.on('authorize', writerKey => { 301 | console.log('authorize', writerKey) 302 | if (!writerKey.match(/^[0-9a-f]{64}$/)) { 303 | customAlert.show('Key must be a 64 character hex value') 304 | return 305 | } 306 | const archive = state.archive 307 | archive.authorize(toBuffer(writerKey, 'hex'), err => { 308 | if (err) { 309 | customAlert.show('Error while authorizing: ' + err.message) 310 | } else { 311 | console.log(`Authorized.`) 312 | customAlert.show('Authorized new writer') 313 | } 314 | emitter.emit('render') 315 | }) 316 | }) 317 | 318 | emitter.on('toggleWriteStatusCollapsed', docName => { 319 | state.writeStatusCollapsed = !state.writeStatusCollapsed 320 | window.localStorage.setItem( 321 | 'writeStatusCollapsed', 322 | state.writeStatusCollapsed 323 | ) 324 | emitter.emit('render') 325 | }) 326 | 327 | emitter.on('downloadZip', () => { 328 | console.log('Download zip') 329 | downloadZip(state.archive) 330 | }) 331 | } 332 | -------------------------------------------------------------------------------- /static/img/bg-landing-page.svg: -------------------------------------------------------------------------------- 1 | bg-landing-page -------------------------------------------------------------------------------- /tiddlywiki/plugins/hyperdrive/hyperdriveadaptor.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const rai = require('random-access-idb') 3 | // const hyperdrive = require('hyperdrive') 4 | const hyperdrive = require('@jimpick/hyperdrive-hyperdb-backend') 5 | const Automerge = require('automerge') 6 | const equal = require('deep-equal') 7 | const jsdiff = require('diff') 8 | const connectToGateway = require('../../../lib/websocketGateway') 9 | const dumpWriters = require('../../../lib/dumpWriters') 10 | 11 | if ($tw.node) return // Client-side only for now 12 | 13 | exports.adaptorClass = HyperdriveAdaptor 14 | 15 | function HyperdriveAdaptor (options) { 16 | this.wiki = options.wiki 17 | this.logger = new $tw.utils.Logger("hyperdrive", {colour: "blue"}) 18 | const match = document.location.pathname.match(/^\/doc\/([0-9a-f]+)\/tw/) 19 | if (!match) { 20 | throw new Error('Could not match key in url') 21 | } 22 | const keyHex = match[1] 23 | const storage = rai(`doc-${keyHex}`) 24 | this.archive = hyperdrive(storage, keyHex) 25 | this.ready = false 26 | this.synced = false 27 | this.archive.ready(() => { 28 | this.ready = true 29 | this.actorKey = this.archive.db.local.key.toString('hex') 30 | dumpWriters(this.archive) 31 | connectToGateway(this.archive) 32 | this.archive.db.watch(() => { 33 | console.log('Archive updated:', this.archive.key.toString('hex')) 34 | dumpWriters(this.archive) 35 | $tw.syncer.syncFromServer() 36 | }) 37 | }) 38 | this.tiddlerDocs = {} 39 | } 40 | 41 | HyperdriveAdaptor.prototype.name = "hyperdrive" 42 | 43 | HyperdriveAdaptor.prototype.isReady = function() { 44 | return this.ready 45 | } 46 | 47 | HyperdriveAdaptor.prototype.getTiddlerInfo = function (tiddler) { 48 | return {} 49 | } 50 | 51 | /* 52 | Get an array of skinny tiddler fields from the archive 53 | */ 54 | 55 | HyperdriveAdaptor.prototype.getSkinnyTiddlers = function (cb) { 56 | this.archive.ready(() => { 57 | this.archive.readdir('tiddlers', (err, list) => { 58 | if (err) return cb(err) 59 | const loadTiddlers = list.reverse().reduce( 60 | (cb, filepath) => { 61 | return (err, result) => { 62 | if (err) return cb(err) 63 | this.loadTiddlerDocMetadata(filepath, (err, metadata) => { 64 | if (err) return cb(err) 65 | if (!metadata) return cb(null, result) 66 | cb(null, [...result, metadata]) 67 | }) 68 | } 69 | }, 70 | (err, result) => { 71 | if (err) return cb(err) 72 | if (!this.synced) { 73 | this.synced = true 74 | if (result.length === 0) { 75 | $tw.wiki.addTiddler({ 76 | title: '$:/DefaultTiddlers', 77 | text: 'GettingStarted' 78 | }) 79 | } 80 | setTimeout(() => { 81 | $tw.rootWidget.dispatchEvent({type: 'tm-home'}) 82 | }, 1000) 83 | } 84 | cb(null, result) 85 | } 86 | ) 87 | loadTiddlers(null, []) 88 | }) 89 | }) 90 | } 91 | 92 | HyperdriveAdaptor.prototype.loadTiddlerDocMetadata = function (filepath, cb) { 93 | const tiddlerDoc = this.getTiddlerDoc(filepath) 94 | const metadataDir = path.join('tiddlers', filepath, 'metadata') 95 | this.archive.readdir(metadataDir, (err, list) => { 96 | if (err) return cb(err) 97 | const changes = list 98 | .map(filename => { 99 | const match = filename.match(/^([0-9a-f]+)\.(\d+)\.json$/) 100 | if (!match) return {} 101 | return { 102 | filename, 103 | actorKey: match[1], 104 | seq: Number(match[2]) 105 | } 106 | }) 107 | .filter(({actorKey, seq}) => { 108 | if (!actorKey) return false 109 | if (!tiddlerDoc.metadataLast[actorKey]) return true 110 | if (seq <= tiddlerDoc.metadataLast[actorKey]) return false 111 | return true 112 | }) 113 | .sort((a, b) => a.seq - b.seq || a.actorKey < b.actorKey) 114 | const loadMetadata = changes.reverse().reduce( 115 | (cb, change) => { 116 | return (err, result) => { 117 | if (err) return cb(err) 118 | const {actorKey, seq, filename} = change 119 | if (!tiddlerDoc.metadataLast[actorKey]) { 120 | tiddlerDoc.metadataLast[actorKey] = 0 121 | } 122 | if (tiddlerDoc.metadataLast[actorKey] != seq - 1) { 123 | // Skip if there are holes in the sequence 124 | console.error('Skipping', filepath, actorKey, seq, 125 | 'wanted', tiddlerDoc.metadataLast[actorKey] + 1) 126 | return cb(null, result) 127 | } 128 | const fullPath = path.join(metadataDir, filename) 129 | this.archive.readFile(fullPath, 'utf-8', (err, data) => { 130 | if (err) return cb(err) 131 | try { 132 | const changeRecord = JSON.parse(data) 133 | changeRecord.actor = actorKey 134 | changeRecord.seq = seq 135 | tiddlerDoc.metadataLast[actorKey]++ 136 | cb(null, [...result, changeRecord]) 137 | } catch (e) { 138 | console.error('JSON parse error', e) 139 | return cb(new Error('JSON parse error')) 140 | } 141 | }) 142 | } 143 | }, 144 | (err, result) => { 145 | if (err) return cb(err) 146 | tiddlerDoc.metadataDoc = Automerge.applyChanges( 147 | tiddlerDoc.metadataDoc, 148 | result 149 | ) 150 | const fields = {...tiddlerDoc.metadataDoc.fields} 151 | for (let propName in fields) { 152 | if (propName === '_conflicts' || propName === '_objectId') { 153 | delete fields[propName] 154 | } 155 | } 156 | for (let propName in fields.list) { 157 | if (propName === '_conflicts' || propName === '_objectId') { 158 | delete fields.list[propName] 159 | } 160 | } 161 | cb(null, fields) 162 | } 163 | ) 164 | loadMetadata(null, []) 165 | }) 166 | } 167 | 168 | HyperdriveAdaptor.prototype.loadTiddlerDocContent = function (filepath, cb) { 169 | const tiddlerDoc = this.getTiddlerDoc(filepath) 170 | const contentDir = path.join('tiddlers', filepath, 'content') 171 | this.archive.readdir(contentDir, (err, list) => { 172 | if (err) return cb(err) 173 | const changes = list 174 | .map(filename => { 175 | const match = filename.match(/^([0-9a-f]+)\.(\d+)\.json$/) 176 | if (!match) return {} 177 | return { 178 | filename, 179 | actorKey: match[1], 180 | seq: Number(match[2]) 181 | } 182 | }) 183 | .filter(({actorKey, seq}) => { 184 | if (!actorKey) return false 185 | if (!tiddlerDoc.contentLast[actorKey]) return true 186 | if (seq <= tiddlerDoc.contentLast[actorKey]) return false 187 | return true 188 | }) 189 | .sort((a, b) => a.seq - b.seq || a.actorKey < b.actorKey) 190 | const loadContent = changes.reverse().reduce( 191 | (cb, change) => { 192 | return (err, result) => { 193 | if (err) return cb(err) 194 | const {actorKey, seq, filename} = change 195 | if (!tiddlerDoc.contentLast[actorKey]) { 196 | tiddlerDoc.contentLast[actorKey] = 0 197 | } 198 | if (tiddlerDoc.contentLast[actorKey] != seq - 1) { 199 | // Skip if there are holes in the sequence 200 | console.error('Skipping', filepath, actorKey, seq, 201 | 'wanted', tiddlerDoc.contentLast[actorKey] + 1) 202 | return cb(null, result) 203 | } 204 | const fullPath = path.join(contentDir, filename) 205 | this.archive.readFile(fullPath, 'utf-8', (err, data) => { 206 | if (err) return cb(err) 207 | try { 208 | const changeRecord = JSON.parse(data) 209 | changeRecord.actor = actorKey 210 | changeRecord.seq = seq 211 | tiddlerDoc.contentLast[actorKey]++ 212 | cb(null, [...result, changeRecord]) 213 | } catch (e) { 214 | console.error('JSON parse error', e) 215 | return cb(new Error('JSON parse error')) 216 | } 217 | }) 218 | } 219 | }, 220 | (err, result) => { 221 | if (err) return cb(err) 222 | tiddlerDoc.contentDoc = Automerge.applyChanges( 223 | tiddlerDoc.contentDoc, 224 | result 225 | ) 226 | const text = tiddlerDoc.contentDoc.text ? 227 | tiddlerDoc.contentDoc.text.join('') : '' 228 | cb(null, text) 229 | } 230 | ) 231 | loadContent(null, []) 232 | }) 233 | } 234 | 235 | HyperdriveAdaptor.prototype.getTiddlerDoc = function (filepath) { 236 | if (!this.tiddlerDocs[filepath]) { 237 | const {actorKey} = this 238 | const metadataDoc = Automerge.init(actorKey) 239 | const contentDoc = Automerge.init(actorKey) 240 | this.tiddlerDocs[filepath] = { 241 | metadataDoc, 242 | metadataLast: {[actorKey]: 0}, 243 | contentDoc, 244 | contentLast: {[actorKey]: 0} 245 | } 246 | } 247 | return this.tiddlerDocs[filepath] 248 | } 249 | 250 | /* 251 | Save a tiddler and invoke the callback with (err,adaptorInfo,revision) 252 | */ 253 | HyperdriveAdaptor.prototype.saveTiddler = function (tiddler, cb) { 254 | const {title} = tiddler.fields 255 | if (title === '$:/StoryList') return cb() 256 | if (tiddler.fields['draft.of']) return cb() // Drafts from other machines 257 | // weren't getting deleted 258 | this.archive.ready(() => { 259 | this.saveMetadata(tiddler, err => { 260 | if (err) return cb(err) 261 | this.saveContent(tiddler, cb) 262 | }) 263 | }) 264 | } 265 | 266 | HyperdriveAdaptor.prototype.saveMetadata = function (tiddler, cb) { 267 | const {actorKey, archive} = this 268 | const {title} = tiddler.fields 269 | const filepath = this.generateTiddlerBaseFilepath(title) 270 | const tiddlerDoc = this.getTiddlerDoc(filepath) 271 | const oldMetadataDoc = tiddlerDoc.metadataDoc 272 | const newMetadataDoc = Automerge.change(oldMetadataDoc, doc => { 273 | if (!doc.fields) { 274 | doc.fields = {} 275 | } 276 | const fields = tiddler.getFieldStrings() 277 | for (const fieldName in fields) { 278 | if (fieldName === 'text') continue 279 | if (!equal(doc.fields[fieldName], fields[fieldName])) { 280 | // FIXME: Should be smarter with fields that are arrays 281 | doc.fields[fieldName] = fields[fieldName] 282 | } 283 | } 284 | }) 285 | tiddlerDoc.metadataDoc = newMetadataDoc 286 | const changes = Automerge.getChanges(oldMetadataDoc, newMetadataDoc) 287 | .filter(change => ( 288 | change.actor === actorKey && 289 | change.seq > tiddlerDoc.metadataLast[actorKey] 290 | )) 291 | 292 | const base = `tiddlers/${filepath}/metadata/${actorKey}` 293 | const save = changes.reverse().reduce( 294 | (cb, change) => { 295 | return err => { 296 | if (err) return cb(err) 297 | const {actor, seq, ...rest} = change 298 | tiddlerDoc.metadataLast[actorKey] = seq 299 | const fullPath = `${base}.${seq}.json` 300 | const json = JSON.stringify(rest) 301 | archive.writeFile(fullPath, json, cb) 302 | } 303 | }, 304 | cb 305 | ) 306 | save() 307 | } 308 | 309 | HyperdriveAdaptor.prototype.saveContent = function (tiddler, cb) { 310 | const {actorKey, archive} = this 311 | const {title} = tiddler.fields 312 | const filepath = this.generateTiddlerBaseFilepath(title) 313 | const tiddlerDoc = this.getTiddlerDoc(filepath) 314 | const oldContentDoc = tiddlerDoc.contentDoc 315 | const newContentDoc = Automerge.change(oldContentDoc, doc => { 316 | if (!doc.text) { 317 | doc.text = new Automerge.Text() 318 | if (tiddler.fields.text) { 319 | doc.text.insertAt(0, ...tiddler.fields.text.split('')) 320 | } 321 | } else { 322 | const oldText = oldContentDoc.text ? 323 | oldContentDoc.text.join('') : '' 324 | const newText = tiddler.fields.text 325 | const diff = jsdiff.diffChars(oldText, newText) 326 | let index = 0 327 | diff.forEach(part => { 328 | if (part.added) { 329 | doc.text.insertAt(index, ...part.value.split('')) 330 | index += part.count 331 | } else if (part.removed) { 332 | doc.text.splice(index, part.count) 333 | } else { 334 | index += part.count 335 | } 336 | }) 337 | } 338 | }) 339 | tiddlerDoc.contentDoc = newContentDoc 340 | const changes = Automerge.getChanges(oldContentDoc, newContentDoc) 341 | .filter(change => ( 342 | change.actor === actorKey && 343 | change.seq > tiddlerDoc.contentLast[actorKey] 344 | )) 345 | 346 | const base = `tiddlers/${filepath}/content/${actorKey}` 347 | const save = changes.reverse().reduce( 348 | (cb, change) => { 349 | return err => { 350 | if (err) return cb(err) 351 | const {actor, seq, ...rest} = change 352 | tiddlerDoc.contentLast[actorKey] = seq 353 | const fullPath = `${base}.${seq}.json` 354 | const json = JSON.stringify(rest) 355 | archive.writeFile(fullPath, json, cb) 356 | } 357 | }, 358 | cb 359 | ) 360 | save() 361 | cb() 362 | } 363 | 364 | /* 365 | Load a tiddler and invoke the callback with (err,tiddlerFields) 366 | */ 367 | HyperdriveAdaptor.prototype.loadTiddler = function (title, cb) { 368 | const filepath = this.generateTiddlerBaseFilepath(title) 369 | this.archive.ready(() => { 370 | this.loadTiddlerDocMetadata(filepath, (err, metadata) => { 371 | if (err) return cb(err) 372 | if (!metadata) return cb(new Error('Missing metadata')) 373 | this.loadTiddlerDocContent(filepath, (err, text) => { 374 | if (err) return cb(err) 375 | cb(null, {...metadata, text}) 376 | }) 377 | }) 378 | }) 379 | } 380 | 381 | /* 382 | Delete a tiddler and invoke the callback with (err) 383 | options include: 384 | tiddlerInfo: the syncer's tiddlerInfo for this tiddler 385 | */ 386 | HyperdriveAdaptor.prototype.deleteTiddler = function (title, cb, options) { 387 | const filepath = this.generateTiddlerBaseFilepath(title) 388 | const baseDir = path.join('tiddlers', filepath) 389 | this.archive.ready(() => { 390 | this.rmdirRecursive(baseDir, cb) 391 | }) 392 | } 393 | 394 | HyperdriveAdaptor.prototype.rmdirRecursive = function (dir, cb) { 395 | this.archive.stat(dir, (err, stat) => { 396 | if (!stat) return cb() 397 | if (stat.isDirectory()) { 398 | this.archive.readdir(dir, (err, list) => { 399 | const deleteAll = list.reverse().reduce( 400 | (cb, filename) => { 401 | return err => { 402 | if (err) return cb(err) 403 | const fullPath = path.join(dir, filename) 404 | this.archive.stat(fullPath, (err, stat) => { 405 | if (err) return cb(err) 406 | if (stat.isDirectory()) { 407 | this.rmdirRecursive(fullPath, cb) 408 | } else if (stat.isFile()) { 409 | this.archive.unlink(fullPath, cb) 410 | } else { 411 | cb(new Error('Not directory or link')) 412 | } 413 | }) 414 | } 415 | }, 416 | err => { 417 | if (err) return cb(err) 418 | this.archive.rmdir(dir, cb) 419 | } 420 | ) 421 | deleteAll() 422 | }) 423 | } else { 424 | return cb(new Error('Not a directory')) 425 | } 426 | }) 427 | } 428 | 429 | // From filesystemadaptor.js 430 | 431 | /* 432 | Given a tiddler title and an array of existing filenames, generate a new 433 | legal filename for the title, case insensitively avoiding the array of 434 | existing filenames 435 | */ 436 | HyperdriveAdaptor.prototype.generateTiddlerBaseFilepath = function (title) { 437 | let baseFilename 438 | // Check whether the user has configured a tiddler -> pathname mapping 439 | const pathNameFilters = this.wiki.getTiddlerText("$:/config/FileSystemPaths") 440 | if (pathNameFilters) { 441 | const source = this.wiki.makeTiddlerIterator([title]) 442 | baseFilename = this.findFirstFilter(pathNameFilters.split("\n"), source) 443 | if (baseFilename) { 444 | // Interpret "/" and "\" as path separator 445 | baseFilename = baseFilename.replace(/\/|\\/g, path.sep) 446 | } 447 | } 448 | if (!baseFilename) { 449 | // No mappings provided, or failed to match this tiddler so we use title as filename 450 | baseFilename = title.replace(/\/|\\/g, "_") 451 | } 452 | // Remove any of the characters that are illegal in Windows filenames 453 | baseFilename = $tw.utils.transliterate( 454 | baseFilename.replace(/<|>|\:|\"|\||\?|\*|\^/g, "_") 455 | ) 456 | // Truncate the filename if it is too long 457 | if (baseFilename.length > 200) { 458 | baseFilename = baseFilename.substr(0, 200) 459 | } 460 | return baseFilename 461 | } 462 | 463 | 464 | --------------------------------------------------------------------------------