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 |
160 |
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 |
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 |
--------------------------------------------------------------------------------