├── .gitattributes ├── .github └── workflows │ └── backup.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── Ideas.md ├── Readme.md ├── examples ├── foood │ ├── .flowconfig │ ├── .gitattributes │ ├── .gitignore │ ├── admin │ │ ├── .gitignore │ │ ├── extract-recipes.js │ │ ├── get-old-foood.js │ │ ├── import-from-foood.js │ │ ├── import-from-hellofresh.js │ │ ├── index.js │ │ ├── package.json │ │ ├── run.js │ │ └── yarn.lock │ ├── collections.js │ ├── icons │ │ ├── favicon.png │ │ ├── icon.svg │ │ ├── icon192.png │ │ ├── icon196.png │ │ ├── icon512.png │ │ ├── icon_plain.svg │ │ ├── icons.svg │ │ ├── knife.svg │ │ └── maskable_icon.png │ ├── index.html │ ├── manifest.webmanifest │ ├── package.json │ ├── private-collections.js │ ├── src │ │ ├── App.js │ │ ├── Comment.js │ │ ├── Drawer.js │ │ ├── EditComment.js │ │ ├── EditMealPlan.js │ │ ├── Editor.js │ │ ├── EditorView.js │ │ ├── Home.js │ │ ├── Ingredients.js │ │ ├── Latest.js │ │ ├── MealPlanButton.js │ │ ├── MealPlans.js │ │ ├── PublicRecipe.js │ │ ├── Queue.js │ │ ├── QueueButton.js │ │ ├── Quill.js │ │ ├── Recipe.js │ │ ├── RecipeBlock.js │ │ ├── RecipeList.js │ │ ├── Search.js │ │ ├── Settings.js │ │ ├── Sidebar.js │ │ ├── Tag.js │ │ ├── TagsEditor.js │ │ ├── Tooltip.js │ │ ├── index.js │ │ ├── parse.js │ │ ├── randomRecipes.js │ │ ├── renderQuill.js │ │ ├── run.js │ │ ├── urlImport.js │ │ └── utils.js │ └── yarn.lock ├── general-server │ ├── .gitignore │ ├── .nvmrc │ ├── Readme.md │ ├── admin.js │ ├── deploy.sh │ ├── glitch-admin.js │ ├── glitch.js │ ├── index.js │ ├── package.json │ ├── treeNotesSchemas.js │ └── yarn.lock ├── planner │ ├── client │ │ ├── .flowconfig │ │ ├── Readme.md │ │ ├── icons │ │ │ ├── favicon.png │ │ │ ├── icon192.png │ │ │ ├── icon196.png │ │ │ ├── icon512.png │ │ │ ├── icons.svg │ │ │ └── maskable_icon.png │ │ ├── index.html │ │ ├── manifest.webmanifest │ │ ├── package.json │ │ ├── src │ │ │ ├── App.js │ │ │ ├── Auth.js │ │ │ ├── Habits │ │ │ │ ├── HabitEditor.js │ │ │ │ └── Habits.js │ │ │ ├── Home.js │ │ │ ├── Schedule │ │ │ │ ├── HierarchicalItemPicker.js │ │ │ │ ├── Hourly.js │ │ │ │ ├── ItemPicker.js │ │ │ │ ├── Schedule.js │ │ │ │ ├── ShowItem.js │ │ │ │ └── dragging.js │ │ │ ├── Shell │ │ │ │ ├── AppShell.js │ │ │ │ ├── Drawer.js │ │ │ │ ├── EditTagDialog.js │ │ │ │ ├── ExportDialog.js │ │ │ │ ├── ImportDialog.js │ │ │ │ └── TopBar.js │ │ │ ├── Split.js │ │ │ ├── TodoList │ │ │ │ ├── Description.js │ │ │ │ ├── Item.js │ │ │ │ ├── ItemChildren.js │ │ │ │ ├── Items.js │ │ │ │ ├── NewItem.js │ │ │ │ └── dragging.js │ │ │ ├── auth-api.js │ │ │ ├── fuzzy.js │ │ │ ├── index.js │ │ │ ├── run.js │ │ │ ├── types.js │ │ │ ├── useLocalStorage.js │ │ │ └── utils.js │ │ └── yarn.lock │ └── server │ │ ├── deploy.sh │ │ ├── getSchema.js │ │ ├── glitch.js │ │ ├── package.json │ │ ├── run.js │ │ └── yarn.lock ├── quill-crdt │ ├── .flowconfig │ ├── .gitignore │ ├── basic.html │ ├── basic.js │ ├── compare.html │ ├── compare.js │ ├── index.html │ ├── index.js │ ├── node.js │ ├── package.json │ └── yarn.lock ├── shared │ ├── AppShell.js │ ├── Auth.js │ ├── Debug.js │ ├── ExportDialog.js │ ├── ImportDialog.js │ ├── SignUpIn.js │ ├── TopBar.js │ ├── Update.js │ ├── auth-api.js │ ├── package.json │ └── yarn.lock ├── simple-example │ ├── .babelrc │ ├── .flowconfig │ ├── .gitignore │ ├── client │ │ ├── index.js │ │ ├── lib.js │ │ ├── persistent-clock.js │ │ ├── simple-index.js │ │ └── test.js │ ├── index.html │ ├── measure.js │ ├── multitest.js │ ├── package.json │ ├── server │ │ ├── index.js │ │ ├── proxy.js │ │ ├── run.js │ │ ├── simple-index.js │ │ └── sqlite-persistence.js │ ├── shared │ │ └── schema.js │ ├── simple │ │ ├── client.js │ │ ├── server.js │ │ ├── simple-poll.js │ │ └── simple-ws.js │ ├── test.html │ ├── test.js │ ├── yarn-error.log │ └── yarn.lock ├── slate-example │ ├── index.html │ ├── index.js │ ├── package.json │ └── yarn.lock ├── things-to-share │ ├── Readme.md │ ├── client │ │ ├── .eslintignore │ │ ├── .eslintrc │ │ ├── .flowconfig │ │ ├── .prettierrc │ │ ├── .workbox-config.js │ │ ├── icons │ │ │ ├── favicon.png │ │ │ ├── icon192.png │ │ │ ├── icon512.png │ │ │ ├── icons.svg │ │ │ └── maskable_icon.png │ │ ├── index.html │ │ ├── manifest.webmanifest │ │ ├── package.json │ │ ├── src │ │ │ ├── Adder.js │ │ │ ├── App.js │ │ │ ├── Auth.js │ │ │ ├── Drawer.js │ │ │ ├── EditTagDialog.js │ │ │ ├── ExportDialog.js │ │ │ ├── Home.js │ │ │ ├── ImportDialog.js │ │ │ ├── LinkItem.js │ │ │ ├── OpenGraph.js │ │ │ ├── TopBar.js │ │ │ ├── auth-api.js │ │ │ ├── index.js │ │ │ └── types.js │ │ └── yarn.lock │ └── server │ │ ├── .flowconfig │ │ ├── .gitignore │ │ ├── cli.js │ │ ├── deploy.sh │ │ ├── getSchema.js │ │ ├── glitch.js │ │ ├── import.js │ │ ├── index.js │ │ ├── open-graph.js │ │ ├── package.json │ │ ├── run.js │ │ ├── translate.js │ │ └── yarn.lock ├── tree-notes │ ├── .flowconfig │ ├── .nvmrc │ ├── Readme.md │ ├── auto-alias.js │ ├── collections.js │ ├── icons │ │ ├── favicon.png │ │ ├── icon192.png │ │ ├── icon196.png │ │ ├── icon512.png │ │ ├── icons.svg │ │ └── maskable_icon.png │ ├── index-collections.js │ ├── index.html │ ├── manifest.webmanifest │ ├── package.json │ ├── src │ │ ├── App.js │ │ ├── BottomBar.js │ │ ├── ChangesDialog.js │ │ ├── ContextMenu.js │ │ ├── CopyDialog.js │ │ ├── Docs.js │ │ ├── Drawer.js │ │ ├── Item.js │ │ ├── Items.js │ │ ├── JumpDialog.js │ │ ├── LocalClient.js │ │ ├── QuillEditor.js │ │ ├── QuillKeyMap.js │ │ ├── SearchPage.js │ │ ├── dragging.js │ │ ├── index.js │ │ ├── navigation.js │ │ ├── run.js │ │ └── types.js │ └── yarn.lock ├── visualize │ ├── chart.js │ ├── compare.html │ ├── compare.js │ ├── index.html │ ├── index.js │ ├── new-chart.js │ ├── package.json │ ├── prose-collab.js │ ├── prosemirror.html │ ├── prosemirror.js │ ├── schema.js │ ├── y-chart.js │ └── yarn.lock └── whiteboard │ ├── client │ ├── .babelrc │ ├── .flowconfig │ ├── AddCard.js │ ├── Auth.js │ ├── Main.js │ ├── Readme.md │ ├── Styles.js │ ├── data.json │ ├── data.txt │ ├── defaults.js │ ├── index.html │ ├── index.js │ ├── package.json │ ├── process.py │ ├── screens │ │ ├── HomePage.js │ │ ├── Phone │ │ │ ├── CardDetail.js │ │ │ └── PhonePiles.js │ │ └── Piles │ │ │ ├── AnimatedPiles.js │ │ │ ├── Cards.js │ │ │ ├── EditableTitle.js │ │ │ ├── Piles.js │ │ │ └── consts.js │ ├── types.js │ ├── uihi.txt │ ├── utils.js │ ├── whiteboard │ │ ├── MiniMap.js │ │ ├── Whiteboard.js │ │ ├── dragUtils.js │ │ ├── state.js │ │ ├── useWhiteboardEvents.js │ │ └── utils.js │ └── yarn.lock │ └── server │ ├── .gitignore │ ├── deploy.sh │ ├── glitch.js │ ├── package.json │ ├── run.js │ └── yarn.lock ├── pack.js ├── package.json ├── packages ├── auth │ ├── .flowconfig │ ├── db.js │ ├── index.js │ ├── package.json │ └── yarn.lock ├── client-bundle │ ├── index.js │ └── package.json ├── client-react │ ├── .gitignore │ ├── index.js │ ├── index.test.js │ ├── package.json │ └── yarn.lock ├── core │ ├── .babelrc │ ├── .flowconfig │ ├── .gitignore │ ├── Makefile │ ├── flow-typed │ │ └── npm │ │ │ └── jest_v25.x.x.js │ ├── package.json │ ├── src │ │ ├── __snapshots__ │ │ │ └── server.test.js.snap │ │ ├── back-off.js │ │ ├── blob │ │ │ ├── basic-network.js │ │ │ └── create-client.js │ │ ├── debounce.js │ │ ├── delta │ │ │ ├── create-client.js │ │ │ ├── delta-mem.js │ │ │ ├── polling-network.js │ │ │ └── websocket-network.js │ │ ├── local-first.js │ │ ├── memory-persistence.js │ │ ├── multi │ │ │ └── create-client.js │ │ ├── peer-tabs.js │ │ ├── persistent-clock.js │ │ ├── poller.js │ │ ├── server.js │ │ ├── server.test.js │ │ ├── shared.js │ │ ├── shared.test.js │ │ ├── types.js │ │ └── undo-manager.js │ └── yarn.lock ├── hybrid-logical-clock │ ├── .babelrc │ ├── Makefile │ ├── Readme.md │ ├── package.json │ ├── src │ │ └── index.js │ └── yarn.lock ├── idb │ ├── .babelrc │ ├── .flowconfig │ ├── Makefile │ ├── package.json │ ├── src │ │ ├── blob.js │ │ ├── delta.js │ │ ├── multi.js │ │ └── types.js │ └── yarn.lock ├── monorepo-pack │ ├── index.js │ ├── package.json │ └── yarn.lock ├── nested-object-crdt │ ├── .babelrc │ ├── .flowconfig │ ├── .gitignore │ ├── Readme.md │ ├── flow-typed │ │ └── npm │ │ │ └── jest_v24.x.x.js │ ├── old │ │ ├── in-place.js │ │ ├── sorted-array.js │ │ └── tests │ │ │ ├── test-full-fuzz.js │ │ │ ├── test-sorted-array.js │ │ │ └── utils │ │ │ ├── generate-deltas.js │ │ │ └── permute.js │ ├── package.json │ ├── src │ │ ├── apply.js │ │ ├── apply.test.js │ │ ├── array-utils.js │ │ ├── array-utils.test.js │ │ ├── create.js │ │ ├── debug.js │ │ ├── deltas.js │ │ ├── in-place-with-array.js │ │ ├── in-place-with-array.test.js │ │ ├── index.js │ │ ├── new.js │ │ ├── other.test.js │ │ ├── schema.js │ │ ├── types.js │ │ └── utils.js │ └── yarn.lock ├── rich-text-crdt │ ├── .babelrc │ ├── .flowconfig │ ├── Readme.md │ ├── apply.js │ ├── check.js │ ├── debug.js │ ├── deltas.js │ ├── fixtures.js │ ├── flow-typed │ │ └── npm │ │ │ └── jest_v25.x.x.js │ ├── index.js │ ├── index.test.js │ ├── loc.js │ ├── loc.test.js │ ├── merge.js │ ├── package.json │ ├── quill-deltas.js │ ├── span.js │ ├── span.test.js │ ├── text-binding.js │ ├── types.js │ ├── utils.js │ └── yarn.lock ├── server-backup │ ├── .gitignore │ ├── Readme.md │ ├── index.js │ ├── package.json │ └── yarn.lock ├── server-bundle │ ├── blob.js │ ├── full.js │ ├── index.js │ ├── package.json │ ├── poll.js │ ├── sqlite-persistence.js │ ├── websocket.js │ └── yarn.lock ├── simple-client │ ├── .babelrc │ ├── index.js │ ├── package.json │ └── yarn.lock └── text-crdt │ ├── .babelrc │ ├── .flowconfig │ ├── Readme.md │ ├── Thoughts.md │ ├── check.js │ ├── cli-editor.js │ ├── debug.js │ ├── loc.js │ ├── loc.test.js │ ├── package.json │ ├── quill-deltas.js │ ├── quill-deltas.test.js │ ├── span.js │ ├── test-format.js │ ├── text-crdt-viz.gif │ ├── tree-test.js │ ├── tree.js │ ├── types.js │ ├── update.js │ ├── utils.js │ └── yarn.lock └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | yarn.lock binary 2 | -------------------------------------------------------------------------------- /.github/workflows/backup.yml: -------------------------------------------------------------------------------- 1 | 2 | # on: 3 | # schedule: 4 | # - cron: '32 15 * * *' 5 | 6 | jobs: 7 | trigger_backup: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - run: 11 | curl $BACKUP_URL 12 | env: 13 | BACKUP_URL: ${{ secrets.BACKUP_URL }} 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .data 3 | dist 4 | node_modules 5 | public 6 | lib 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "printWidth": 100, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.printWidth": 100, 3 | "prettier.trailingComma": "all", 4 | "prettier.singleQuote": true, 5 | "eslint.enable": true, 6 | "editor.tabSize": 4 7 | } 8 | -------------------------------------------------------------------------------- /Ideas.md: -------------------------------------------------------------------------------- 1 | # Schema-based server rejection 2 | 3 | the server knows what the schema is for a given collection; and if something comes in that violates the schema, the delta is rejected & the whole node is send back with an "override" flag. 4 | This would require/assume that the server gets updated before the clients. 5 | 6 | # Collection-level ACLs 7 | 8 | Would you be able to do everything you want with only collection-level ACLs? 9 | This would mean that e.g. "comments" on a doc would live in a separate collection from the doc itself. 10 | 11 | Also, would you be able to approximate the "suggestion" feature of google docs? I think those would also be stored in the "comments" collection, with some metadata on the comment indicating the suggestion. 12 | 13 | So you would do something like `setAccess(collectionId, access)` 14 | 15 | where access is 16 | 17 | ``` 18 | type Access = { 19 | type: 'public', 20 | } | { 21 | type: 'private', 22 | } 23 | ``` 24 | 25 | # I should include an example that has local full-text search via lunar.js or something 26 | 27 | # Google drive 28 | 29 | So we're syncing to a file, I imagine, and that file will be a collection of collections. 30 | So for notablemind, it would be 31 | 32 | ``` 33 | { 34 | meta: {'': {title, nodeCount, etc.}}, 35 | tags: {[tid]: Tag}, 36 | nodes: {[nid]: Node}, 37 | } 38 | ``` 39 | 40 | So this means there's a notion of a "database" that is a group of collections. 41 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # local-first packages & explorations 2 | 3 | This aims to eventually be a fully-featured solution for managing, syncing, and storing application data, in a way that works offline, and collaboratively. See [this article](https://www.inkandswitch.com/local-first.html) for more info about the name. 4 | 5 | Currently implemented 6 | - a hybrid logical clock ([blog post](https://jaredforsyth.com/posts/hybrid-logical-clocks/), [code](https://github.com/jaredly/local-first/tree/master/packages/hybrid-logical-clock)) 7 | - a nested object CRDT ([code](https://github.com/jaredly/local-first/tree/master/packages/nested-object-crdt)) 8 | - a rich-text CRDT ([code](https://github.com/jaredly/local-first/tree/master/packages/text-crdt), [example integration with quill](https://github.com/jaredly/local-first/tree/master/examples/quill-crdt), [visualization of the data structure](https://github.com/jaredly/local-first/tree/master/examples/visualize)) 9 | 10 | 11 | ## Data migrations 12 | 13 | What if: 14 | - when changing the model: 15 | - clients who are behind would show a message, and would be able to *send* updates, but not *receive* them 16 | - the server would need to know how to interpret updates from the previous version 17 | - when the user clicks "upgrade me", we finish sending our updates, and then teardown the clients and refetch all data from the server. (this requires the server to be reifying nodes, otherwise it's a little ridiculous) 18 | - then refresh the page? probably. 19 | - oh but also we need to manage the upgrading of the client to the new javascript 🤔.... I'm not sure how to do that. 20 | - well I mean I guess if the service worker installs the new infos, and the app starts up and sees that data hasn't been upgraded, it can just enter "upgrading mode", and do that. 21 | - how do I think about upgrading "deltas"? Like, if someone comes online and is like "give me the past 2 weeks of changes", I would need to have upgraded them, right? But that seems fraught. 22 | - so it's better to just send them ... all nodes that have changed, right? 23 | Wait no. We can assume that data model upgrades represent a hard break; noone will have "partial" upgraded data. They either have pre-break non-upgraded data, or post-break upgraded data. There's no way to have pre-break upgraded data, or post-break non-upgraded data. Because the upgrade process involves a full refresh. 24 | 25 | Bugs/questions: 26 | - why, when updating from the admin script, does the website not do realtime updates? 27 | - I need an auth status that is "invalid/expired", so that people can keep on with their lives, and re-sign in when they're ready. 28 | -------------------------------------------------------------------------------- /examples/foood/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/bower\.json 3 | .*/config\.json 4 | 5 | [include] 6 | ../../packages 7 | ../shared 8 | 9 | [libs] 10 | 11 | [lints] 12 | sketchy-null=error 13 | sketchy-null-bool=off 14 | 15 | [options] 16 | esproposal.optional_chaining=enable 17 | 18 | module.name_mapper='@birchill/json-equalish' -> '/node_modules/@birchill/json-equalish' 19 | module.name_mapper='@material-ui/core/AppBar' -> '/node_modules/@material-ui/core/AppBar' 20 | module.name_mapper='@material-ui/core/Button' -> '/node_modules/@material-ui/core/Button' 21 | module.name_mapper='@material-ui/core/CircularProgress' -> '/node_modules/@material-ui/core/CircularProgress' 22 | module.name_mapper='@material-ui/core/Container' -> '/node_modules/@material-ui/core/Container' 23 | module.name_mapper='@material-ui/core/Dialog' -> '/node_modules/@material-ui/core/Dialog' 24 | module.name_mapper='@material-ui/core/DialogTitle' -> '/node_modules/@material-ui/core/DialogTitle' 25 | module.name_mapper='@material-ui/core/Grid' -> '/node_modules/@material-ui/core/Grid' 26 | module.name_mapper='@material-ui/core/IconButton' -> '/node_modules/@material-ui/core/IconButton' 27 | module.name_mapper='@material-ui/core/Paper' -> '/node_modules/@material-ui/core/Paper' 28 | module.name_mapper='@material-ui/core/Snackbar' -> '/node_modules/@material-ui/core/Snackbar' 29 | module.name_mapper='@material-ui/core/TextField' -> '/node_modules/@material-ui/core/TextField' 30 | module.name_mapper='@material-ui/core/Toolbar' -> '/node_modules/@material-ui/core/Toolbar' 31 | module.name_mapper='@material-ui/core/Typography' -> '/node_modules/@material-ui/core/Typography' 32 | module.name_mapper='@material-ui/core/styles' -> '/node_modules/@material-ui/core/styles' 33 | module.name_mapper='@material-ui/icons/Close' -> '/node_modules/@material-ui/icons/Close' 34 | module.name_mapper='@material-ui/icons/Menu' -> '/node_modules/@material-ui/icons/Menu' 35 | module.name_mapper='@material-ui/icons/Wifi' -> '/node_modules/@material-ui/icons/Wifi' 36 | module.name_mapper='@material-ui/icons/WifiOff' -> '/node_modules/@material-ui/icons/WifiOff' 37 | module.name_mapper='pako' -> '/node_modules/pako' 38 | module.name_mapper='react' -> '/node_modules/react' 39 | module.name_mapper='react-router-dom' -> '/node_modules/react-router-dom' 40 | 41 | [strict] -------------------------------------------------------------------------------- /examples/foood/.gitattributes: -------------------------------------------------------------------------------- 1 | *.svg binary 2 | -------------------------------------------------------------------------------- /examples/foood/.gitignore: -------------------------------------------------------------------------------- 1 | .import 2 | -------------------------------------------------------------------------------- /examples/foood/admin/.gitignore: -------------------------------------------------------------------------------- 1 | .session 2 | .session-prod 3 | recipes.json 4 | tags.json 5 | *.json 6 | !package.json 7 | -------------------------------------------------------------------------------- /examples/foood/admin/get-old-foood.js: -------------------------------------------------------------------------------- 1 | var admin = require('firebase-admin'); 2 | 3 | var serviceAccount = require('./foood-admin.json'); 4 | const fs = require('fs'); 5 | 6 | admin.initializeApp({ 7 | credential: admin.credential.cert(serviceAccount), 8 | databaseURL: 'https://foood-465a5.firebaseio.com', 9 | }); 10 | 11 | const getList = (m) => { 12 | const res = []; 13 | m.forEach((m) => res.push(m.data())); 14 | return res; 15 | }; 16 | 17 | const run = async () => { 18 | const db = admin.firestore(); 19 | 20 | const collections = ['ingredients', 'lists', 'madeIts', 'recipes']; 21 | for (const collection of collections) { 22 | console.log(collection); 23 | const data = await db.collection(collection).get(); 24 | fs.writeFileSync(`./${collection}.json`, JSON.stringify(getList(data), null, 2)); 25 | } 26 | 27 | // await citiesRef.doc('SF').set({ 28 | // name: 'San Francisco', 29 | // state: 'CA', 30 | // country: 'USA', 31 | // capital: false, 32 | // population: 860000, 33 | // }); 34 | // await citiesRef.doc('LA').set({ 35 | // name: 'Los Angeles', 36 | // state: 'CA', 37 | // country: 'USA', 38 | // capital: false, 39 | // population: 3900000, 40 | // }); 41 | // await citiesRef.doc('DC').set({ 42 | // name: 'Washington, D.C.', 43 | // state: null, 44 | // country: 'USA', 45 | // capital: true, 46 | // population: 680000, 47 | // }); 48 | // await citiesRef.doc('TOK').set({ 49 | // name: 'Tokyo', 50 | // state: null, 51 | // country: 'Japan', 52 | // capital: true, 53 | // population: 9000000, 54 | // }); 55 | // await citiesRef.doc('BJ').set({ 56 | // name: 'Beijing', 57 | // state: null, 58 | // country: 'China', 59 | // capital: true, 60 | // population: 21500000, 61 | // }); 62 | }; 63 | 64 | run(); 65 | -------------------------------------------------------------------------------- /examples/foood/admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "firebase-admin": "^9.4.2" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/foood/admin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('../node_modules/regenerator-runtime'); 4 | require('@babel/register')({ 5 | ignore: [/node_modules/], 6 | presets: ['@babel/preset-flow', '@babel/preset-env'], 7 | plugins: ['@babel/plugin-proposal-class-properties'], 8 | }); 9 | 10 | // $FlowFixMe it's fine 11 | const fetch = require('../../planner/server/node_modules/node-fetch'); 12 | global.fetch = fetch; 13 | require('./index.js'); 14 | 15 | // const image = 'https://www.javirecipes.com/arroz-con-leche/'; 16 | // const image = 17 | // 'https://getpocket.com/explore/item/we-tried-8-methods-of-cooking-bacon-and-found-an-absolute-winner'; 18 | // const image = 19 | // // ok 20 | // 'https://www.thespicehouse.com/blogs/recipes/lamb-korma-recipe'; 21 | // // 'https://www.youtube.com/watch?v=dj8tuQ1RojM'; 22 | // // 'https://petitworldcitizen.com/2015/02/08/hazelnut-buckwheat-granola-bars/'; 23 | // // 'https://www.joyofbaking.com/BruttimaBuoni.html'; 24 | // // 'https://figjamandlimecordial.com/2010/09/18/braided-loaves/'; 25 | // // 'https://www.thespruceeats.com/bok-choy-chicken-soup-694299'; 26 | // // 'http://www.ecurry.com/blog/condiments-dips-and-sauces/beetroot-raita-lightly-seasoned-beetroot-and-yogurt-salad/'; 27 | // const { findImageFromPage } = require('./import-from-foood'); 28 | // findImageFromPage(image).then( 29 | // (res) => { 30 | // console.log('found', res); 31 | // }, 32 | // (err) => { 33 | // console.log('nope'); 34 | // console.error(err); 35 | // }, 36 | // ); 37 | -------------------------------------------------------------------------------- /examples/foood/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/foood/icons/favicon.png -------------------------------------------------------------------------------- /examples/foood/icons/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/foood/icons/icon192.png -------------------------------------------------------------------------------- /examples/foood/icons/icon196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/foood/icons/icon196.png -------------------------------------------------------------------------------- /examples/foood/icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/foood/icons/icon512.png -------------------------------------------------------------------------------- /examples/foood/icons/icon_plain.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 18 | 21 | 26 | 31 | 35 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/foood/icons/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/foood/icons/maskable_icon.png -------------------------------------------------------------------------------- /examples/foood/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Foood 11 | 64 | 65 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /examples/foood/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Foood", 3 | "name": "Foood", 4 | "description": "Recipe manager", 5 | "start_url": "/?source=pwa", 6 | "icons": [ 7 | { 8 | "src": "/icons/icon512.png", 9 | "type": "image/png", 10 | "sizes": "512x512" 11 | }, 12 | { 13 | "src": "/icons/icon192.png", 14 | "type": "image/png", 15 | "sizes": "192x192" 16 | }, 17 | { 18 | "src": "/icons/maskable_icon.png", 19 | "type": "image/png", 20 | "sizes": "192x192", 21 | "purpose": "maskable" 22 | } 23 | ], 24 | "background_color": "#ff9800", 25 | "display": "standalone", 26 | "scope": "/", 27 | "theme_color": "#ff9800", 28 | "share_target": { 29 | "action": "/recipe/new", 30 | "method": "GET", 31 | "enctype": "application/x-www-form-urlencoded", 32 | "params": { 33 | "title": "title", 34 | "text": "text", 35 | "url": "url" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/foood/src/randomRecipes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { RecipeT, TagT, IngredientT } from '../collections'; 3 | import type { Settings, PantryIngredient } from '../private-collections'; 4 | 5 | /* 6 | 7 | What's our strategy here? 8 | - x% from approved 9 | - x% from 'to try' 10 | - x% from uncategorized 11 | 12 | maybe 7 from each? or 10 from each? 13 | 14 | 15 | */ 16 | 17 | export const generateRecipes = ( 18 | meal: string, 19 | settings: Settings, 20 | recipes: *, 21 | tags: *, 22 | pantryIngredients: *, 23 | ingredientsToUse: Array, 24 | actorId: string, 25 | ) => { 26 | const tagsToUse = Object.keys(settings[meal + 'Tags']); 27 | const recipesToUse = { approved: [], 'to try': [], undefined: [] }; 28 | Object.keys(recipes).forEach((id) => { 29 | if (!tagsToUse.some((tid) => recipes[id].tags[tid])) { 30 | return; 31 | } 32 | if (recipesToUse[recipes[id].statuses[actorId]] != null) { 33 | recipesToUse[recipes[id].statuses[actorId]].push(id); 34 | } 35 | }); 36 | const picked = []; 37 | for (let i = 0; i < 10; i++) { 38 | const at = parseInt(Math.random() * recipesToUse.approved.length); 39 | picked.push(recipesToUse.approved[at]); 40 | recipesToUse.approved.splice(at, 1); 41 | } 42 | for (let i = 0; i < 10; i++) { 43 | const at = parseInt(Math.random() * recipesToUse['to try'].length); 44 | picked.push(recipesToUse['to try'][at]); 45 | recipesToUse['to try'].splice(at, 1); 46 | } 47 | for (let i = 0; i < 5; i++) { 48 | const at = parseInt(Math.random() * recipesToUse.undefined.length); 49 | picked.push(recipesToUse.undefined[at]); 50 | recipesToUse.undefined.splice(at, 1); 51 | } 52 | return picked; 53 | }; 54 | -------------------------------------------------------------------------------- /examples/foood/src/run.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import 'regenerator-runtime/runtime'; 3 | import run from './'; 4 | 5 | run(); 6 | -------------------------------------------------------------------------------- /examples/foood/src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { IngredientT } from '../collections'; 4 | 5 | export const getIngredient = (ingredients: { [key: string]: IngredientT }, id: string) => { 6 | let ing = ingredients[id]; 7 | while (ing?.mergedInto != null) { 8 | ing = ingredients[ing.mergedInto]; 9 | } 10 | return ing; 11 | }; 12 | 13 | export const imageUrl = (image: string, serverUrl: string) => { 14 | if (image.startsWith('foood://')) { 15 | return serverUrl + '/uploads/' + image.slice('foood://'.length); 16 | } 17 | if (image.startsWith('http://') && window.location.protocol === 'https:') { 18 | return `https://${image.slice('http://'.length)}`; 19 | } 20 | return image; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/general-server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | foood-*.json 3 | cloudflare.sh -------------------------------------------------------------------------------- /examples/general-server/.nvmrc: -------------------------------------------------------------------------------- 1 | 13 2 | -------------------------------------------------------------------------------- /examples/general-server/Readme.md: -------------------------------------------------------------------------------- 1 | # Um usage 2 | 3 | 4 | ``` 5 | env SECRET=some-secret node index.js 6 | ``` -------------------------------------------------------------------------------- /examples/general-server/admin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | }); 8 | 9 | require('./glitch-admin.js'); 10 | -------------------------------------------------------------------------------- /examples/general-server/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | cd $DIR 4 | 5 | cd ../../ 6 | (cd public/general-server && git pull origin master) 7 | node pack general-server 8 | cd public/general-server 9 | git add . 10 | git commit -am 'update' 11 | git push origin master:update 12 | -------------------------------------------------------------------------------- /examples/general-server/glitch-admin.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const { getAuthDb } = require('../../packages/server-bundle/full'); 4 | const { setupPersistence } = require('../../packages/server-bundle/sqlite-persistence'); 5 | const dataPath = '.data/store'; 6 | 7 | const { 8 | createUser, 9 | fulfillInvite, 10 | validateSessionToken, 11 | loginUser, 12 | createUserSession, 13 | completeUserSession, 14 | checkUserExists, 15 | listUsers, 16 | listInvites, 17 | setUserRole, 18 | createInvite, 19 | } = require('../../packages/auth/db'); 20 | 21 | const [_, __, cmd, ...args] = process.argv; 22 | if (cmd === 'ls-users') { 23 | console.log(listUsers(getAuthDb(dataPath))); 24 | } else if (cmd === 'ls-invites') { 25 | console.log(listInvites(getAuthDb(dataPath))); 26 | } else if (cmd === 'mod-role') { 27 | const [userEmail, newRole] = args; 28 | if (isNaN(+newRole)) { 29 | console.error('Invalid args. Expected a string and a number.'); 30 | process.exit(1); 31 | } 32 | if (!setUserRole(getAuthDb(dataPath), userEmail, +newRole)) { 33 | console.log(`User with email ${userEmail} not found`); 34 | } else { 35 | console.log(`User successfully updated.`); 36 | } 37 | } else if (cmd === 'create-invite') { 38 | const id = createInvite(getAuthDb(dataPath), Date.now()); 39 | console.log(`New invite created! https://foood2.surge.sh/?invite=${id}`); 40 | } else if (cmd === 'compact') { 41 | const db = args[0]; 42 | if (!fs.existsSync(db)) { 43 | console.error(`No database at ${db}`); 44 | process.exit(1); 45 | } 46 | console.warn('This will modify your database! I hope you made a backup.'); 47 | const persistence = setupPersistence(db); 48 | const collection = args[1]; 49 | } 50 | -------------------------------------------------------------------------------- /examples/general-server/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | }); 8 | 9 | require('./glitch.js'); 10 | -------------------------------------------------------------------------------- /examples/general-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.9.0", 4 | "@babel/plugin-proposal-class-properties": "^7.8.3", 5 | "@babel/preset-env": "^7.9.0", 6 | "@babel/preset-flow": "^7.9.0", 7 | "@babel/register": "^7.9.0", 8 | "dotenv": "^8.2.0", 9 | "flow-bin": "^0.123.0" 10 | }, 11 | "scripts": { 12 | "dev": "node -r dotenv/config index.js" 13 | }, 14 | "dependencies": { 15 | "firebase-admin": "^8.13.0", 16 | "regenerator-runtime": "^0.13.7" 17 | }, 18 | "engines": { 19 | "node": ">=12.x" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/general-server/treeNotesSchemas.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const ItemSchema = { 4 | type: 'object', 5 | attributes: { 6 | id: 'string', 7 | author: 'string', 8 | body: 'rich-text', 9 | tags: { type: 'map', value: 'number' }, 10 | createdDate: 'number', 11 | completed: { type: 'optional', value: 'number' }, 12 | 13 | children: 'id-array', 14 | 15 | columnData: { type: 'map', value: 'rich-text' }, 16 | childColumnConfig: { 17 | type: 'optional', 18 | value: { 19 | type: 'object', 20 | attributes: { 21 | recursive: 'boolean', 22 | columns: { 23 | type: 'map', 24 | value: { 25 | type: 'object', 26 | attributes: { 27 | title: 'string', 28 | kind: 'string', 29 | width: { type: 'optional', value: 'number' }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | 37 | // hmk right? 38 | 39 | style: 'string', // header | code | quote | todo 40 | theme: 'string', // list | blog | whiteboard | mindmap 41 | numbering: { 42 | type: 'optional', 43 | value: { 44 | type: 'object', 45 | attributes: { 46 | style: 'string', 47 | startWith: { type: 'optional', value: 'number' }, 48 | }, 49 | }, 50 | }, // {style: numbers | letters | roman, startWith?: number} 51 | 52 | trashed: { type: 'optional', value: 'number' }, 53 | // {[reaction-name]: {[userid]: date}} 54 | reactions: { type: 'map', value: { type: 'map', value: 'number' } }, 55 | }, 56 | }; 57 | 58 | const schemas = { 59 | items: ItemSchema, 60 | }; 61 | 62 | module.exports = schemas; 63 | -------------------------------------------------------------------------------- /examples/planner/client/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/bower\.json 3 | .*/config\.json 4 | 5 | [include] 6 | ../../../packages 7 | 8 | [libs] 9 | 10 | [lints] 11 | sketchy-null=error 12 | sketchy-null-bool=off 13 | 14 | [options] 15 | 16 | [strict] 17 | -------------------------------------------------------------------------------- /examples/planner/client/Readme.md: -------------------------------------------------------------------------------- 1 | # Todo 2 | 3 | I really need to sort out this "cannot update something I don't have" error. 4 | It's happening in the wild, sometimes after a long wait kind of thing. 5 | 6 | Certainly the simplest option would be: when I encounter a syncing error like this, I request 7 | a full dump of the data state. 8 | 9 | And then its all good. 10 | 11 | Yeah let's do that. 12 | 13 | So, that doesn't fix "I made a change and forgot to send it". But we can assume that won't happen much? 14 | 15 | 16 | 17 | Ok, so I need to write a test, right? 18 | Do I even have tests? 19 | 20 | 21 | 22 | Um so apparently my fix didn't even totally work? There was some more broken way that things were? including some node with undefined children? idk. 23 | oh, it was that there was an update being applied out of order. which definitely shouldn't happen? 24 | And didn't happen once I deleted the app & restarted it.... 25 | -------------------------------------------------------------------------------- /examples/planner/client/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/planner/client/icons/favicon.png -------------------------------------------------------------------------------- /examples/planner/client/icons/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/planner/client/icons/icon192.png -------------------------------------------------------------------------------- /examples/planner/client/icons/icon196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/planner/client/icons/icon196.png -------------------------------------------------------------------------------- /examples/planner/client/icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/planner/client/icons/icon512.png -------------------------------------------------------------------------------- /examples/planner/client/icons/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/planner/client/icons/maskable_icon.png -------------------------------------------------------------------------------- /examples/planner/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Planner 7 | 16 | 17 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/planner/client/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Planner", 3 | "name": "Planner", 4 | "description": "Plan to do things, and then do them", 5 | "start_url": "/?source=pwa", 6 | "icons": [ 7 | { 8 | "src": "/icons/icon512.png", 9 | "type": "image/png", 10 | "sizes": "512x512" 11 | }, 12 | { 13 | "src": "/icons/icon192.png", 14 | "type": "image/png", 15 | "sizes": "192x192" 16 | }, 17 | { 18 | "src": "/icons/maskable_icon.png", 19 | "type": "image/png", 20 | "sizes": "192x192", 21 | "purpose": "maskable" 22 | } 23 | ], 24 | "background_color": "#4caf50", 25 | "display": "standalone", 26 | "scope": "/", 27 | "theme_color": "#4caf50" 28 | } 29 | -------------------------------------------------------------------------------- /examples/planner/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "things-to-share-client", 3 | "private": true, 4 | "version": "1.0.0", 5 | "dependencies": { 6 | "@birchill/json-equalish": "^1.1.0", 7 | "@emotion/core": "^10.0.28", 8 | "@material-ui/core": "^4.9.9", 9 | "@material-ui/icons": "^4.9.1", 10 | "@material-ui/lab": "^4.0.0-alpha.49", 11 | "@types/react": "^16.8.6", 12 | "broadcast-channel": "^3.0.3", 13 | "gravatar-url": "^3.1.0", 14 | "idb": "^5.0.1", 15 | "pako": "^1.0.11", 16 | "parse-color": "^1.0.0", 17 | "querystring": "^0.2.0", 18 | "react": "^16.8.0", 19 | "react-dom": "^16.8.0", 20 | "react-router-dom": "^5.1.2", 21 | "react-spring": "^8.0.27", 22 | "react-use-measure": "^2.0.0", 23 | "typeface-roboto": "^0.0.75" 24 | }, 25 | "scripts": { 26 | "deploy": "npm run build && cd dist && cp index.html 200.html && surge . local-planner.surge.sh", 27 | "build": "parcel build index.html", 28 | "start": "parcel index.html" 29 | }, 30 | "browserslist": [ 31 | "last 1 Chrome versions" 32 | ], 33 | "devDependencies": { 34 | "@babel/core": "^7.0.0-0", 35 | "@babel/plugin-proposal-class-properties": "^7.8.3", 36 | "@babel/register": "^7.8.3", 37 | "babel-eslint": "^10.1.0", 38 | "eslint": "^6.8.0", 39 | "eslint-plugin-react": "^7.19.0", 40 | "fast-deep-equal": "^3.1.1", 41 | "flow-bin": "^0.116.1", 42 | "parcel": "^1.12.4", 43 | "parcel-plugin-workbox-cache": "^2.0.0", 44 | "prettier": "^2.0.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/planner/client/src/Habits/HabitEditor.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Button from '@material-ui/core/Button'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Cancel from '@material-ui/icons/Cancel'; 7 | import Folder from '@material-ui/icons/Folder'; 8 | import * as React from 'react'; 9 | import { Link, useParams } from 'react-router-dom'; 10 | import type { Collection, Client, SyncStatus } from '../../../../../packages/client-bundle'; 11 | import { useCollection, useItem } from '../../../../../packages/client-react'; 12 | import type { AuthData } from '../App'; 13 | import AppShell from '../Shell/AppShell'; 14 | import { Item } from '../TodoList/Item'; 15 | import { type HabitT, type ItemT, newHabit } from '../types'; 16 | import { nextDay, parseDate, prevDay, showDate } from '../utils'; 17 | 18 | const HabitEditor = (props: { 19 | title: string, 20 | description: string, 21 | goalFrequency: ?number, 22 | onSave: (string, string, ?number) => void, 23 | onCancel: () => void, 24 | }) => { 25 | const [title, setTitle] = React.useState(props.title); 26 | const [description, setDescription] = React.useState(props.description); 27 | const [goalFrequency, setGoalFrequency] = React.useState(props.goalFrequency); 28 | 29 | return ( 30 |
31 | setTitle(evt.target.value)} 36 | fullWidth 37 | /> 38 | setDescription(evt.target.value)} 43 | fullWidth 44 | /> 45 | 52 | setGoalFrequency(evt.target.value.length ? +evt.target.value : null) 53 | } 54 | /> 55 |
56 | 59 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default HabitEditor; 66 | -------------------------------------------------------------------------------- /examples/planner/client/src/Home.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Container from '@material-ui/core/Container'; 3 | import ListItem from '@material-ui/core/ListItem'; 4 | import Switch from '@material-ui/core/Switch'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import * as React from 'react'; 8 | import type { Client, SyncStatus } from '../../../../packages/client-bundle'; 9 | import type { Data } from './auth-api'; 10 | import Items from './TodoList/Items'; 11 | 12 | import AppShell from './Shell/AppShell'; 13 | 14 | import { Route, Switch as RouteSwitch, useRouteMatch } from 'react-router-dom'; 15 | import type { AuthData } from './App'; 16 | 17 | const Home = ({ 18 | client, 19 | // logout, 20 | // host, 21 | // auth, 22 | authData, 23 | }: { 24 | client: Client, 25 | // logout: () => mixed, 26 | // host: string, 27 | // auth: ?Data, 28 | authData: ?AuthData, 29 | }) => { 30 | const [showAll, setShowAll] = React.useState(false); 31 | const styles = useStyles(); 32 | const match = useRouteMatch(); 33 | 34 | return ( 35 | 38 | setShowAll(!showAll)} 43 | color="primary" 44 | /> 45 | } 46 | label="Show completed" 47 | /> 48 | 49 | } 50 | authData={authData} 51 | // auth={auth} 52 | // host={host} 53 | // logout={logout} 54 | client={client} 55 | > 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | const useStyles = makeStyles((theme) => ({ 69 | container: { 70 | paddingTop: theme.spacing(4), 71 | paddingBottom: theme.spacing(4), 72 | }, 73 | })); 74 | 75 | export default Home; 76 | -------------------------------------------------------------------------------- /examples/planner/client/src/Shell/AppShell.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Container from '@material-ui/core/Container'; 3 | import ListItem from '@material-ui/core/ListItem'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import * as React from 'react'; 7 | import type { Client, SyncStatus } from '../../../../../packages/client-bundle'; 8 | import { useCollection } from '../../../../../packages/client-react'; 9 | import type { Data } from '../auth-api'; 10 | import Drawer from './Drawer'; 11 | import Items from '../TodoList/Items'; 12 | import TopBar from './TopBar'; 13 | 14 | import { Route, Switch, useRouteMatch } from 'react-router-dom'; 15 | import type { AuthData } from '../App'; 16 | 17 | const AppShell = ({ 18 | client, 19 | // logout, 20 | // host, 21 | // auth, 22 | authData, 23 | drawerItems, 24 | children, 25 | noContainer, 26 | }: { 27 | client: Client, 28 | // logout: () => mixed, 29 | // host: string, 30 | // auth: ?Data, 31 | authData: ?AuthData, 32 | children: React.Node, 33 | drawerItems: React.Node, 34 | noContainer?: boolean, 35 | }) => { 36 | const [menu, setMenu] = React.useState(false); 37 | const styles = useStyles(); 38 | const match = useRouteMatch(); 39 | 40 | return ( 41 | 42 | setMenu(true)} client={client} /> 43 | setMenu(false)} 46 | open={menu} 47 | authData={authData} 48 | client={client} 49 | /> 50 | 51 | {children} 52 | 53 | 54 | ); 55 | }; 56 | 57 | const useStyles = makeStyles((theme) => ({ 58 | container: { 59 | paddingTop: theme.spacing(4), 60 | paddingBottom: theme.spacing(4), 61 | }, 62 | })); 63 | 64 | export default AppShell; 65 | -------------------------------------------------------------------------------- /examples/planner/client/src/Split.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Container from '@material-ui/core/Container'; 3 | import ListItem from '@material-ui/core/ListItem'; 4 | import Switch from '@material-ui/core/Switch'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import * as React from 'react'; 8 | import type { Client, SyncStatus } from '../../../../packages/client-bundle'; 9 | import type { Data } from './auth-api'; 10 | import Items from './TodoList/Items'; 11 | 12 | import AppShell from './Shell/AppShell'; 13 | import { Schedule } from './Schedule/Schedule'; 14 | 15 | import { Route, Switch as RouteSwitch, useRouteMatch, useParams } from 'react-router-dom'; 16 | import type { AuthData } from './App'; 17 | 18 | const Split = ({ client, authData }: { client: Client, authData: ?AuthData }) => { 19 | const { day } = useParams(); 20 | const [showAll, setShowAll] = React.useState(false); 21 | 22 | return ( 23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default Split; 37 | -------------------------------------------------------------------------------- /examples/planner/client/src/TodoList/Description.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import Cancel from '@material-ui/icons/Cancel'; 6 | import CheckCircle from '@material-ui/icons/CheckCircle'; 7 | import * as React from 'react'; 8 | 9 | const INDENT = 24; 10 | 11 | const Description = ({ text, onChange }: { text: string, onChange: (string) => void }) => { 12 | const styles = useStyles(); 13 | const [editing, onEdit] = React.useState(null); 14 | return editing != null ? ( 15 |
16 | onEdit(evt.target.value)} 21 | onKeyDown={(evt) => { 22 | if (evt.key === 'Enter' && (evt.metaKey || evt.shiftKey || evt.ctrlKey)) { 23 | if (editing != text) { 24 | onChange(editing); 25 | } 26 | onEdit(null); 27 | } 28 | }} 29 | /> 30 | { 32 | if (editing != text) { 33 | onChange(editing); 34 | } 35 | onEdit(null); 36 | }} 37 | > 38 | 39 | 40 | onEdit(null)}> 41 | 42 | 43 |
44 | ) : ( 45 |
onEdit(text)} 47 | style={{ 48 | fontStyle: 'italic', 49 | whiteSpace: 'pre-wrap', 50 | }} 51 | > 52 | {!!text ? text : 'Add description'} 53 |
54 | ); 55 | }; 56 | 57 | export default Description; 58 | 59 | const useStyles = makeStyles((theme) => ({})); 60 | -------------------------------------------------------------------------------- /examples/planner/client/src/TodoList/NewItem.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import AddBoxOutlined from '@material-ui/icons/Add'; 5 | import * as React from 'react'; 6 | 7 | const INDENT = 24; 8 | 9 | const NewItem = ({ 10 | onAdd, 11 | level, 12 | onFocus, 13 | }: { 14 | onAdd: (string) => void, 15 | level: number, 16 | onFocus: (boolean) => void, 17 | }) => { 18 | const [text, setText] = React.useState(''); 19 | const styles = useStyles(); 20 | 21 | return ( 22 |
23 |
24 | { 27 | if (text.trim().length > 0) { 28 | onAdd(text); 29 | setText(''); 30 | } 31 | }} 32 | > 33 | 34 | 35 | setText(evt.target.value)} 39 | placeholder="Add item" 40 | className={styles.input} 41 | onFocus={() => onFocus(true)} 42 | onBlur={() => onFocus(false)} 43 | onKeyDown={(evt) => { 44 | if (evt.key === 'Enter' && text.trim().length > 0) { 45 | onAdd(text); 46 | setText(''); 47 | } 48 | }} 49 | /> 50 |
51 | ); 52 | }; 53 | 54 | export default NewItem; 55 | 56 | const useStyles = makeStyles((theme) => ({ 57 | input: { 58 | color: 'inherit', 59 | width: '100%', 60 | // fontSize: 32, 61 | padding: '4px 8px', 62 | backgroundColor: 'inherit', 63 | border: 'none', 64 | // borderBottom: `2px solid ${theme.palette.primary.dark}`, 65 | ...theme.typography.body1, 66 | fontWeight: 300, 67 | }, 68 | inputWrapper: { 69 | display: 'flex', 70 | flexDirection: 'row', 71 | alignItems: 'center', 72 | }, 73 | })); 74 | -------------------------------------------------------------------------------- /examples/planner/client/src/fuzzy.js: -------------------------------------------------------------------------------- 1 | const fuzzyScore = (exactWeight, query, term) => { 2 | if (query.length === 0) { 3 | return { 4 | loc: -1, 5 | score: 0, 6 | full: true, 7 | exact: false, 8 | breaks: 0, 9 | breakSize: 0, 10 | len: term.length, 11 | }; 12 | } 13 | query = query.toLowerCase(); 14 | term = term.toLowerCase(); 15 | if (query === term) { 16 | return { 17 | loc: 0, 18 | score: exactWeight, 19 | full: true, 20 | exact: true, 21 | breaks: 0, 22 | breakSize: 0, 23 | len: term.length, 24 | }; 25 | } 26 | if (term.indexOf(query) !== -1) { 27 | return { 28 | loc: term.indexOf(query), 29 | score: exactWeight, 30 | exact: false, 31 | full: true, 32 | breaks: 0, 33 | breakSize: 0, 34 | len: term.length, 35 | }; 36 | } 37 | let qi = 0, 38 | ti = 0, 39 | score = 0, 40 | loc = -1, 41 | matchedLast = true, 42 | breaks = 0, 43 | breakSize = 0; 44 | for (; qi < query.length && ti < term.length; ) { 45 | if (query[qi] === term[ti]) { 46 | score = score + (matchedLast ? 3 : 1); 47 | loc = qi === 0 ? ti : loc; 48 | qi++; 49 | matchedLast = true; 50 | } else { 51 | if (matchedLast && loc !== -1) { 52 | breaks += 1; 53 | } 54 | if (loc !== -1) { 55 | breakSize += 1; 56 | } 57 | matchedLast = false; 58 | } 59 | ti++; 60 | } 61 | return { 62 | loc, 63 | score, 64 | full: ti >= term.length, 65 | exact: false, 66 | breaks, 67 | breakSize, 68 | len: term.length, 69 | }; 70 | }; 71 | 72 | const fuzzysearch = (needle, haystack) => { 73 | if (needle.length > haystack.length) { 74 | return false; 75 | } 76 | if (needle.length === haystack.length) { 77 | return needle === haystack; 78 | } 79 | if (needle.length === 0) { 80 | return true; 81 | } 82 | for (let i = 0, j = 0; i < needle.length && j < haystack.length; ) { 83 | if (needle[i] === haystack[j]) { 84 | i++; 85 | } 86 | j++; 87 | } 88 | return false; 89 | }; 90 | -------------------------------------------------------------------------------- /examples/planner/client/src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { render } from 'react-dom'; 3 | import React from 'react'; 4 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 5 | import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom'; 6 | 7 | import 'typeface-roboto'; 8 | 9 | import CssBaseline from '@material-ui/core/CssBaseline'; 10 | 11 | import Auth from './Auth'; 12 | import App from './App'; 13 | 14 | const darkTheme = createMuiTheme({ 15 | palette: { 16 | type: 'dark', 17 | 18 | primary: { main: '#4caf50' }, 19 | secondary: { 20 | main: '#76ff03', 21 | }, 22 | }, 23 | }); 24 | 25 | // const host = 'localhost:9090'; 26 | // const host = 'things-to-share.glitch.me'; 27 | // window.addEventListener('load', () => {}); 28 | 29 | const Top = () => { 30 | return ( 31 | 32 | 33 | 34 |
35 | 36 | 37 |
38 | 39 | 40 |
41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | const Main = ({ host, dbName }: { host: ?string, dbName: string }) => { 48 | return ( 49 | 50 | 51 | {host != null ? ( 52 | ( 55 | 56 | )} 57 | /> 58 | ) : ( 59 | 60 | )} 61 | 62 | ); 63 | }; 64 | 65 | const run = () => { 66 | const node = document.createElement('div'); 67 | if (!document.body) { 68 | return; 69 | } 70 | document.body.appendChild(node); 71 | render(, node); 72 | }; 73 | 74 | export default run; 75 | -------------------------------------------------------------------------------- /examples/planner/client/src/run.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import run from './'; 3 | 4 | run(); 5 | // if (window.location.pathname === '/local') { 6 | // run(null, 'planner-blob'); 7 | // } else if (window.location.pathname === '/localhost') { 8 | // run('localhost:9090', 'planner'); 9 | // } else { 10 | // run('planner-server.glitch.me', 'planner-glitch'); 11 | // } 12 | -------------------------------------------------------------------------------- /examples/planner/client/src/useLocalStorage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | 4 | export const useLocalStorageState = function (key: string, initial: T) { 5 | const [current, setCurrent] = React.useState(() => { 6 | const raw = localStorage[key]; 7 | return raw == null ? initial : JSON.parse(raw); 8 | }); 9 | const set = React.useCallback( 10 | (value: T) => { 11 | localStorage[key] = JSON.stringify(value); 12 | setCurrent(value); 13 | }, 14 | [setCurrent], 15 | ); 16 | return [current, set]; 17 | }; 18 | 19 | const sharedState = {}; 20 | const saveTimers = {}; 21 | 22 | export const useLocalStorageSharedToggle = (sharedKey: string, key: string) => { 23 | const [current, setCurrent] = React.useState(() => { 24 | if (sharedState[sharedKey] == null) { 25 | const raw = localStorage[sharedKey]; 26 | sharedState[sharedKey] = raw == null ? {} : JSON.parse(raw); 27 | } 28 | return sharedState[sharedKey][key]; 29 | }); 30 | const set = React.useCallback( 31 | (value: boolean) => { 32 | if (value) { 33 | sharedState[sharedKey][key] = value; 34 | } else { 35 | delete sharedState[sharedKey][key]; 36 | } 37 | if (!saveTimers[sharedKey]) { 38 | saveTimers[sharedKey] = setTimeout(() => { 39 | saveTimers[sharedKey] = null; 40 | localStorage[sharedKey] = JSON.stringify(sharedState[sharedKey]); 41 | }, 5); 42 | } 43 | setCurrent(value); 44 | }, 45 | [setCurrent], 46 | ); 47 | return [current, set]; 48 | }; 49 | -------------------------------------------------------------------------------- /examples/planner/client/src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const interleave = function (items: Array, fn: (number) => T): Array { 4 | const res = []; 5 | items.forEach((item, i) => { 6 | if (i > 0) { 7 | res.push(fn(i)); 8 | } 9 | res.push(item); 10 | }); 11 | return res; 12 | }; 13 | 14 | export const cx = (items: Array) => items.filter(Boolean).join(' '); 15 | 16 | export const sameDay = (one: Date, two: Date) => { 17 | return ( 18 | one.getFullYear() === two.getFullYear() && 19 | one.getMonth() === two.getMonth() && 20 | one.getDate() === two.getDate() 21 | ); 22 | }; 23 | 24 | export const isToday = (date: Date) => { 25 | return sameDay(date, today()); 26 | }; 27 | 28 | export const today = () => { 29 | const now = new Date(); 30 | // start of day 31 | now.setHours(0, 0, 0, 0); 32 | return now; 33 | }; 34 | 35 | export const nextDay = (date: Date) => { 36 | const next = new Date(date.getTime() + 36 * 3600 * 1000); 37 | next.setHours(0, 0, 0, 0); 38 | return next; 39 | }; 40 | 41 | export const prevDay = (date: Date) => { 42 | const prev = new Date(date.getTime() - 12 * 3600 * 1000); 43 | prev.setHours(0, 0, 0, 0); 44 | return prev; 45 | }; 46 | 47 | export const tomorrow = () => { 48 | return nextDay(today()); 49 | }; 50 | 51 | export const showDate = (date: Date) => 52 | `${date.getFullYear()}-${(date.getMonth() + 1) 53 | .toString() 54 | .padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; 55 | 56 | export const parseDate = (text: string) => { 57 | const [year, month, date] = text.split('-'); 58 | const d = new Date(); 59 | d.setHours(0, 0, 0, 0); 60 | d.setFullYear(+year); 61 | d.setMonth(+month - 1); 62 | d.setDate(+date); 63 | return d; 64 | }; 65 | -------------------------------------------------------------------------------- /examples/planner/server/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | cd $DIR 4 | 5 | cd ../../../ 6 | (cd public/planner-server && git pull origin master) 7 | node pack planner 8 | cd public/planner-server 9 | git commit -am 'update' 10 | git push origin master:update 11 | -------------------------------------------------------------------------------- /examples/planner/server/getSchema.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { ItemSchema, TagSchema, HabitSchema, DaySchema, TimeSchema } from '../client/src/types'; 4 | import { validateDelta } from '../../../packages/nested-object-crdt/src/schema'; 5 | 6 | const schemas = { 7 | items: ItemSchema, 8 | tags: TagSchema, 9 | habits: HabitSchema, 10 | days: DaySchema, 11 | times: TimeSchema, 12 | }; 13 | 14 | export const getSchemaChecker = (colid: string) => 15 | schemas[colid] ? delta => validateDelta(schemas[colid], delta) : null; 16 | -------------------------------------------------------------------------------- /examples/planner/server/glitch.js: -------------------------------------------------------------------------------- 1 | const { run } = require('../../../packages/server-bundle/full.js'); 2 | const dataPath = '.data'; 3 | const port = process.env.PORT || 9090; 4 | const { getSchemaChecker } = require('./getSchema'); 5 | const result = run(dataPath, getSchemaChecker, port); 6 | console.log('listening on ' + port); 7 | -------------------------------------------------------------------------------- /examples/planner/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.9.0", 4 | "@babel/plugin-proposal-class-properties": "^7.8.3", 5 | "@babel/preset-env": "^7.9.0", 6 | "@babel/preset-flow": "^7.9.0", 7 | "@babel/register": "^7.9.0", 8 | "flow-bin": "^0.123.0" 9 | }, 10 | "dependencies": { 11 | "better-sqlite3": "^6.0.1", 12 | "node-fetch": "^2.6.0", 13 | "node-html-parser": "^1.2.16", 14 | "open-graph-scraper": "^3.6.2" 15 | }, 16 | "scripts": { 17 | "deploy": "./deploy.sh" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/planner/server/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | }); 8 | const { run } = require('../../../packages/server-bundle/full.js'); 9 | const dataPath = __dirname + '/.data'; 10 | const port = process.env.PORT != null ? parseInt(process.env.PORT) : 9090; 11 | const { getSchemaChecker } = require('./getSchema'); 12 | const result = run(dataPath, getSchemaChecker, port); 13 | console.log('listening on ' + port); 14 | -------------------------------------------------------------------------------- /examples/quill-crdt/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../packages/text-crdt 5 | ../../packages/rich-text-crdt 6 | ../../packages/hybrid-logical-clock 7 | ../../packages/nested-object-crdt 8 | 9 | [libs] 10 | 11 | [lints] 12 | 13 | [options] 14 | 15 | [strict] 16 | -------------------------------------------------------------------------------- /examples/quill-crdt/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | -------------------------------------------------------------------------------- /examples/quill-crdt/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/quill-crdt/compare.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/quill-crdt/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/quill-crdt/node.js: -------------------------------------------------------------------------------- 1 | export const node = (tag, attrs, children) => { 2 | const node = document.createElement(tag); 3 | if (attrs) { 4 | Object.keys(attrs).forEach(attr => { 5 | if (attr === 'style') { 6 | Object.assign(node.style, attrs[attr]); 7 | } else if (typeof attrs[attr] === 'function') { 8 | // $FlowFixMe 9 | node[attr] = attrs[attr]; 10 | } else { 11 | node.setAttribute(attr, attrs[attr]); 12 | } 13 | }); 14 | } 15 | if (children) { 16 | const add = child => { 17 | if (Array.isArray(child)) { 18 | child.forEach(add); 19 | } else if (typeof child === 'string' || typeof child === 'number') { 20 | node.appendChild(document.createTextNode(child.toString())); 21 | } else if (child) { 22 | node.appendChild(child); 23 | } 24 | }; 25 | add(children); 26 | } 27 | return node; 28 | }; 29 | export const div = (attrs, children) => node('div', attrs, children); 30 | export const span = (attrs, children) => node('span', attrs, children); 31 | 32 | export const addDiv = (attrs, children) => { 33 | return add(div(attrs, children)); 34 | }; 35 | 36 | export const add = node => { 37 | if (document.body) { 38 | document.body.appendChild(node); 39 | } 40 | return node; 41 | }; 42 | -------------------------------------------------------------------------------- /examples/quill-crdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "parcel": "^1.12.4", 4 | "quill": "^1.3.7", 5 | "quill-cursors": "https://github.com/jaredly/quill-cursors#quill-cursors-v3.0.2-gitpkg", 6 | "y-protocols": "^0.2.0", 7 | "y-quill": "^0.0.2", 8 | "yjs": "^13.0.4" 9 | }, 10 | "devDependencies": { 11 | "@babel/core": "^7.8.4", 12 | "@babel/plugin-proposal-class-properties": "^7.8.3", 13 | "flow-bin": "^0.120.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/shared/AppShell.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Container from '@material-ui/core/Container'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import * as React from 'react'; 5 | import type { Client, SyncStatus } from '../../packages/client-bundle'; 6 | import { useCollection } from '../../packages/client-react'; 7 | import type { Data } from './auth-api'; 8 | import TopBar from './TopBar'; 9 | import type { AuthData } from './Auth'; 10 | 11 | import { Route, Switch, useRouteMatch } from 'react-router-dom'; 12 | 13 | const AppShell = ({ 14 | client, 15 | authData, 16 | drawerItems, 17 | children, 18 | noContainer, 19 | renderDrawer, 20 | title, 21 | topIcons, 22 | }: { 23 | client: Client<*>, 24 | authData: ?AuthData, 25 | children: React.Node, 26 | drawerItems: React.Node, 27 | noContainer?: boolean, 28 | renderDrawer: (boolean, () => void) => React.Node, 29 | title: string, 30 | topIcons?: React.Node, 31 | }) => { 32 | const [menu, setMenu] = React.useState(false); 33 | const styles = useStyles(); 34 | const match = useRouteMatch(); 35 | 36 | const openMenu = React.useCallback(() => setMenu(true), []); 37 | 38 | return ( 39 | 40 | 41 | {renderDrawer(menu, () => setMenu(false))} 42 | 43 | {children} 44 | 45 | 46 | ); 47 | }; 48 | 49 | const useStyles = makeStyles(theme => ({ 50 | container: { 51 | paddingTop: theme.spacing(4), 52 | paddingBottom: theme.spacing(4), 53 | }, 54 | })); 55 | 56 | export default AppShell; 57 | -------------------------------------------------------------------------------- /examples/shared/Debug.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import Button from '@material-ui/core/Button'; 4 | // import IconButton from '@material-ui/core/IconButton'; 5 | // import SearchIcon from '@material-ui/icons/Search'; 6 | // import AddIcon from '@material-ui/icons/Add'; 7 | // import { useCollection, useItem } from '../../../packages/client-react'; 8 | // import type { Data } from '../shared/auth-api'; 9 | // import type { AuthData } from '../shared/Auth'; 10 | import type { Client, Collection } from '../../packages/client-bundle'; 11 | import ExportDialog from './ExportDialog'; 12 | import ImportDialog from './ImportDialog'; 13 | 14 | const Debug = ({ client }: { client: Client<*> }) => { 15 | const [dialog, setDialog] = React.useState(null); 16 | 17 | return ( 18 |
19 | Debug actions: 20 |
21 | {' '} 30 | (delete all data, to refresh from the server) 31 |
32 |
33 | 36 |
37 |
38 | 41 |
42 | setDialog(null)} 46 | /> 47 | setDialog(null)} 51 | /> 52 |
53 | ); 54 | }; 55 | 56 | export default Debug; 57 | -------------------------------------------------------------------------------- /examples/shared/ImportDialog.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Dialog from '@material-ui/core/Dialog'; 3 | import DialogTitle from '@material-ui/core/DialogTitle'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import TextField from '@material-ui/core/TextField'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import pako from 'pako'; 8 | import * as React from 'react'; 9 | import type { Client } from '../../packages/client-bundle'; 10 | 11 | const genId = () => 12 | Math.random() 13 | .toString(36) 14 | .slice(2); 15 | 16 | const useStyles = makeStyles(theme => ({ 17 | text: { 18 | padding: theme.spacing(2), 19 | }, 20 | })); 21 | 22 | const ImportDialog = ({ 23 | client, 24 | onClose, 25 | open, 26 | }: { 27 | client: Client<*>, 28 | onClose: () => void, 29 | open: boolean, 30 | }) => { 31 | const styles = useStyles(); 32 | const [loading, setLoading] = React.useState(false); 33 | const id = React.useMemo(() => 'id-' + genId(), []); 34 | 35 | return ( 36 | 37 | Data Import 38 | 39 | Import the things 40 | 41 | { 44 | setLoading(true); 45 | if (evt.target.files.length > 0) { 46 | const reader = new FileReader(); 47 | reader.onload = evt => { 48 | try { 49 | const data = JSON.parse( 50 | // $FlowFixMe 51 | pako.inflate(evt.target.result, { 52 | to: 'string', 53 | }), 54 | ); 55 | client.importDump(data).then( 56 | () => { 57 | onClose(); 58 | }, 59 | err => { 60 | console.error(err); 61 | }, 62 | ); 63 | } catch (err) { 64 | console.error(err); 65 | } 66 | }; 67 | reader.readAsArrayBuffer(evt.target.files[0]); 68 | } 69 | }} 70 | id="standard-basic" 71 | label="Standard" 72 | type="file" 73 | /> 74 | 75 | ); 76 | }; 77 | 78 | export default ImportDialog; 79 | -------------------------------------------------------------------------------- /examples/shared/Update.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Button from '@material-ui/core/Button'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import Snackbar from '@material-ui/core/Snackbar'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | import * as React from 'react'; 8 | 9 | export const useUpgrade = () => { 10 | const [showUpgrade, setShowUpgrade] = React.useState( 11 | window.upgradeAvailable && window.upgradeAvailable.installed, 12 | ); 13 | 14 | React.useEffect(() => { 15 | if (window.upgradeAvailable) { 16 | // console.log('listeneing'); 17 | const listener = () => { 18 | // console.log('listenered!'); 19 | setShowUpgrade(true); 20 | }; 21 | window.upgradeAvailable.listeners.push(listener); 22 | return () => { 23 | // console.log('unlistenerd'); 24 | window.upgradeAvailable.listeners = window.upgradeAvailable.listeners.filter( 25 | f => f !== listener, 26 | ); 27 | }; 28 | } else { 29 | console.log('no upgrade support'); 30 | } 31 | }, []); 32 | 33 | const hideUpgrade = React.useCallback(() => setShowUpgrade(false), []); 34 | const acceptUpgrade = React.useCallback(() => { 35 | window.upgradeAvailable.waiting.postMessage({ 36 | type: 'SKIP_WAITING', 37 | }); 38 | hideUpgrade(); 39 | }); 40 | 41 | return [showUpgrade, acceptUpgrade, hideUpgrade]; 42 | }; 43 | 44 | const UpdateSnackbar = React.memo<{}>(() => { 45 | const [upgradeAvailable, acceptUpgrade, hideUpgradeAvaialble] = useUpgrade(); 46 | 47 | return ( 48 | hideUpgradeAvaialble()} 56 | message="Update available" 57 | action={ 58 | 59 | 62 | hideUpgradeAvaialble()} 67 | > 68 | 69 | 70 | 71 | } 72 | /> 73 | ); 74 | }); 75 | 76 | export default UpdateSnackbar; 77 | -------------------------------------------------------------------------------- /examples/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@birchill/json-equalish": "^1.1.0", 4 | "@material-ui/core": "^4.11.3", 5 | "@types/react": "^16.8.6 || ^17.0.0", 6 | "react": "^16.8.0 || ^17.0.0", 7 | "react-dom": "^16.8.0 || ^17.0.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/simple-example/.babelrc: -------------------------------------------------------------------------------- 1 | {"presets": ["@babel/preset-flow", "@babel/preset-env"]} 2 | -------------------------------------------------------------------------------- /examples/simple-example/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../packages 5 | 6 | [libs] 7 | 8 | [lints] 9 | 10 | [options] 11 | 12 | [strict] 13 | -------------------------------------------------------------------------------- /examples/simple-example/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .data 3 | .test-data 4 | dist 5 | -------------------------------------------------------------------------------- /examples/simple-example/client/lib.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as hlc from '../../../packages/hybrid-logical-clock'; 4 | export type { HLC } from '../../../packages/hybrid-logical-clock'; 5 | import * as crdt from '../../../packages/nested-object-crdt'; 6 | export type { Delta, CRDT as Data } from '../../../packages/nested-object-crdt'; 7 | export { hlc, crdt }; 8 | 9 | export { default as createBlobClient } from '../../../packages/core/src/blob/create-client'; 10 | export { default as makeBlobPersistence } from '../../../packages/idb/src/blob'; 11 | export { default as createBasicBlobNetwork } from '../../../packages/core/src/blob/basic-network'; 12 | 13 | export { default as createDeltaClient } from '../../../packages/core/src/delta/create-client'; 14 | export { default as makeDeltaPersistence } from '../../../packages/idb/src/delta'; 15 | export { default as createPollingNetwork } from '../../../packages/core/src/delta/polling-network'; 16 | export { default as createWebSocketNetwork } from '../../../packages/core/src/delta/websocket-network'; 17 | 18 | export { PersistentClock } from './persistent-clock'; 19 | -------------------------------------------------------------------------------- /examples/simple-example/client/persistent-clock.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as hlc from '../../../packages/hybrid-logical-clock'; 3 | import type { HLC } from '../../../packages/hybrid-logical-clock'; 4 | 5 | export const localStorageClockPersist = (key: string) => ({ 6 | get(init: () => HLC): HLC { 7 | const raw = localStorage.getItem(key); 8 | if (!raw) { 9 | const res = init(); 10 | localStorage.setItem(key, hlc.pack(res)); 11 | return res; 12 | } 13 | return hlc.unpack(raw); 14 | }, 15 | set(clock: HLC) { 16 | localStorage.setItem(key, hlc.pack(clock)); 17 | }, 18 | }); 19 | 20 | const genId = () => 21 | Math.random() 22 | .toString(36) 23 | .slice(2); 24 | 25 | type ClockPersist = { get: (() => HLC) => HLC, set: HLC => void }; 26 | 27 | export class PersistentClock { 28 | persist: ClockPersist; 29 | now: HLC; 30 | constructor(persist: ClockPersist) { 31 | this.persist = persist; 32 | this.now = persist.get(() => hlc.init(genId(), Date.now())); 33 | // $FlowFixMe 34 | this.get = this.get.bind(this); 35 | // $FlowFixMe 36 | this.set = this.set.bind(this); 37 | // $FlowFixMe 38 | this.recv = this.recv.bind(this); 39 | } 40 | 41 | get() { 42 | this.now = hlc.inc(this.now, Date.now()); 43 | this.persist.set(this.now); 44 | return hlc.pack(this.now); 45 | } 46 | 47 | set(newClock: HLC) { 48 | this.now = newClock; 49 | this.persist.set(this.now); 50 | } 51 | 52 | recv(newClock: HLC) { 53 | this.now = hlc.recv(this.now, newClock, Date.now()); 54 | this.persist.set(this.now); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/simple-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/simple-example/measure.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // A simple tool for calculating the "actual size of data stored" in an sqlite database (for benchmarks). 3 | const sqlite = require('better-sqlite3'); 4 | if (process.argv.length < 3) { 5 | console.log('Usage: measure.js input.db'); 6 | } 7 | const db = sqlite(process.argv[2]); 8 | 9 | function queryAll(db, sql, params = []) { 10 | let stmt = db.prepare(sql); 11 | // console.log('query all', sql, params); 12 | return stmt.all(...params); 13 | } 14 | 15 | const tables = queryAll(db, 'select name from sqlite_master'); 16 | const data = {}; 17 | tables.forEach(name => { 18 | data[name.name] = queryAll( 19 | db, 20 | 'select * from ' + JSON.stringify(name.name), 21 | ); 22 | }); 23 | console.log(JSON.stringify(data).length / 1000, 'kb'); 24 | -------------------------------------------------------------------------------- /examples/simple-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-example-client", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@birchill/json-equalish": "^1.1.0", 6 | "@emotion/core": "^10.0.28", 7 | "better-sqlite3": "^5.4.3", 8 | "body-parser": "^1.19.0", 9 | "broadcast-channel": "^3.0.3", 10 | "cookie-parser": "^1.4.4", 11 | "cookie-session": "^1.4.0", 12 | "cors": "^2.8.5", 13 | "express": "^4.17.1", 14 | "express-ws": "^4.0.0", 15 | "idb": "^5.0.1", 16 | "leveldown": "^5.4.1", 17 | "levelup": "^4.3.2", 18 | "parcel": "^1.12.4", 19 | "react": "^16.13.1", 20 | "react-dom": "^16.13.1" 21 | }, 22 | "browserslist": [ 23 | "last 1 Chrome versions" 24 | ], 25 | "devDependencies": { 26 | "@babel/core": "^7.8.3", 27 | "@babel/register": "^7.8.3", 28 | "fast-deep-equal": "^3.1.1", 29 | "flow-bin": "^0.116.1", 30 | "puppeteer": "^2.1.0", 31 | "puppeteer-core": "^2.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/simple-example/server/proxy.js: -------------------------------------------------------------------------------- 1 | // server.js 2 | 3 | const WebSocket = require('ws'); 4 | 5 | const wss = new WebSocket.Server({ port: 9104 }); 6 | 7 | const url = 'http://localhost:9900'; 8 | 9 | wss.on('connection', (ws, req) => { 10 | const connection = new WebSocket(url + req.url); 11 | let pending = []; 12 | 13 | const stats = { toServer: 0, fromServer: 0 }; 14 | 15 | connection.onopen = () => { 16 | console.log('server opened'); 17 | if (pending) { 18 | pending.forEach(m => connection.send(m)); 19 | pending = null; 20 | } 21 | // connection.send('Message From Client'); 22 | }; 23 | 24 | connection.onerror = error => { 25 | console.error('err', error); 26 | // console.log(`WebSocket error: ${JSON.stringify(error)}`); 27 | }; 28 | 29 | connection.onmessage = e => { 30 | console.log('here'); 31 | stats.fromServer += e.data.length; 32 | console.log('from server', e.data.length, stats); 33 | // console.log(e.data); 34 | try { 35 | ws.send(e.data); 36 | } catch (e) { 37 | console.log('failed sending message'); 38 | console.error(e); 39 | } 40 | }; 41 | 42 | connection.onclose = () => { 43 | console.log('server closed'); 44 | ws.close(); 45 | }; 46 | 47 | ws.on('close', () => { 48 | console.log('client close'); 49 | connection.close(); 50 | }); 51 | 52 | ws.on('message', message => { 53 | stats.toServer += message.length; 54 | console.log('from client', message.length, stats); 55 | if (pending) { 56 | pending.push(message); 57 | } else { 58 | connection.send(message); 59 | } 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /examples/simple-example/server/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | }); 7 | const { runServer, makeServer } = require('./index.js'); 8 | const dataPath = __dirname + '/.data'; 9 | runServer(9900, dataPath, makeServer(dataPath)); 10 | console.log('listening on 9900'); 11 | -------------------------------------------------------------------------------- /examples/simple-example/shared/schema.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { type Schema } from '../../../packages/nested-object-crdt/src/schema.js'; 3 | 4 | const ItemSchema: Schema = { 5 | type: 'object', 6 | attributes: { 7 | completed: 'boolean', 8 | title: 'string', 9 | createdDate: 'int', 10 | tags: { type: 'map', value: 'boolean' }, 11 | }, 12 | }; 13 | 14 | const NoteSchema: Schema = { 15 | type: 'object', 16 | attributes: { 17 | title: 'string', 18 | body: 'rich-text', 19 | createDate: 'int', 20 | }, 21 | }; 22 | 23 | module.exports = { ItemSchema, NoteSchema }; 24 | -------------------------------------------------------------------------------- /examples/simple-example/simple/simple-poll.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import makeClient, { 3 | getCollection, 4 | onMessage, 5 | syncMessages, 6 | syncFailed, 7 | syncSucceeded, 8 | debounce, 9 | type ClientState, 10 | type CRDTImpl, 11 | } from './client'; 12 | import backOff from '../../../packages/core/src/back-off'; 13 | 14 | const sync = async function( 15 | url: string, 16 | client: ClientState, 17 | ) { 18 | const messages = syncMessages(client.collections); 19 | console.log('messages', messages); 20 | const res = await fetch(`${url}?sessionId=${client.sessionId}`, { 21 | method: 'POST', 22 | headers: { 'Content-Type': 'application/json' }, 23 | body: JSON.stringify(messages), 24 | }); 25 | if (res.status !== 200) { 26 | throw new Error(`Unexpected status ${res.status}`); 27 | } 28 | syncSucceeded(client.collections); 29 | const data = await res.json(); 30 | data.forEach(message => onMessage(client, message)); 31 | }; 32 | 33 | const poller = (time, fn) => { 34 | let tid = null; 35 | const poll = () => { 36 | clearTimeout(tid); 37 | fn() 38 | .catch(() => {}) 39 | .then(() => { 40 | // tid = setTimeout(poll, time); 41 | }); 42 | }; 43 | document.addEventListener( 44 | 'visibilitychange', 45 | () => { 46 | if (document.hidden) { 47 | clearTimeout(tid); 48 | } else { 49 | poll(); 50 | } 51 | }, 52 | false, 53 | ); 54 | window.addEventListener( 55 | 'focus', 56 | () => { 57 | poll(); 58 | }, 59 | false, 60 | ); 61 | return poll; 62 | }; 63 | 64 | export default function( 65 | url: string, 66 | sessionId: string, 67 | crdt: CRDTImpl, 68 | ): ClientState { 69 | const poll = poller( 70 | 3 * 1000, 71 | () => 72 | new Promise(res => { 73 | backOff(() => 74 | sync(url, client).then( 75 | () => { 76 | res(); 77 | return true; 78 | }, 79 | err => { 80 | syncFailed(client.collections); 81 | return false; 82 | }, 83 | ), 84 | ); 85 | }), 86 | ); 87 | const client = makeClient(crdt, sessionId, debounce(poll), [ 88 | 'tasks', 89 | ]); 90 | 91 | poll(); 92 | return client; 93 | } 94 | -------------------------------------------------------------------------------- /examples/simple-example/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /examples/simple-example/yarn-error.log: -------------------------------------------------------------------------------- 1 | Arguments: 2 | /Users/jared/.fnm/node-versions/v8.16.2/installation/bin/node /Users/jared/.fnm/current/bin/yarn 3 | 4 | PATH: 5 | /Users/jared/.fnm/current/bin:/Users/jared/.fnm:/Users/jared/.local/bin:/Users/jared/khan/mobile/.github/actions/.bin:/Users/jared/Library/Android/sdk/emulator/:/Users/jared/Library/Android/sdk/tools/bin/:/Users/jared/Library/Android/sdk/platform-tools/:/Users/jared/khan/devtools/google-cloud-sdk/bin:/Users/jared/.nix-profile/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/jared/.fnm/current/bin:/Users/jared/.fnm:/Users/jared/.local/bin:/Users/jared/khan/mobile/.github/actions/.bin:/Users/jared/Library/Android/sdk/emulator/:/Users/jared/Library/Android/sdk/tools/bin/:/Users/jared/Library/Android/sdk/platform-tools/:/Users/jared/khan/devtools/google-cloud-sdk/bin:/Users/jared/.nix-profile/bin 6 | 7 | Yarn version: 8 | 1.21.0 9 | 10 | Node version: 11 | 8.16.2 12 | 13 | Platform: 14 | darwin x64 15 | 16 | Trace: 17 | Error: https://registry.yarnpkg.com/@local-first%2fhybrid-logical-clock: Not found 18 | at Request.params.callback [as _callback] (/Users/jared/.fnm/node-versions/v8.16.2/installation/src/node_modules/yarn/src/cli.js:66938:18) 19 | at Request.self.callback (/Users/jared/.fnm/node-versions/v8.16.2/installation/src/node_modules/yarn/src/cli.js:140622:22) 20 | at emitTwo (events.js:126:13) 21 | at Request.emit (events.js:214:7) 22 | at Request. (/Users/jared/.fnm/node-versions/v8.16.2/installation/src/node_modules/yarn/src/cli.js:141594:10) 23 | at emitOne (events.js:116:13) 24 | at Request.emit (events.js:211:7) 25 | at IncomingMessage. (/Users/jared/.fnm/node-versions/v8.16.2/installation/src/node_modules/yarn/src/cli.js:141516:12) 26 | at Object.onceWrapper (events.js:313:30) 27 | at emitNone (events.js:111:20) 28 | 29 | npm manifest: 30 | { 31 | "name": "simple-example-client", 32 | "version": "1.0.0", 33 | "dependencies": { 34 | "../../../packages/hybrid-logical-clock": "^1.0", 35 | "../../../packages/nested-object-crdt": "^1.0", 36 | "parcel": "^1.12.4", 37 | "react": "^16.12.0", 38 | "react-dom": "^16.12.0", 39 | "body-parser": "^1.19.0", 40 | "cors": "^2.8.5", 41 | "express": "^4.17.1", 42 | "express-ws": "^4.0.0" 43 | }, 44 | "browserslist": [ 45 | "last 1 Chrome versions" 46 | ], 47 | "devDependencies": { 48 | "@babel/register": "^7.8.3", 49 | "flow-bin": "^0.116.1" 50 | } 51 | } 52 | 53 | yarn manifest: 54 | No manifest 55 | 56 | Lockfile: 57 | No lockfile 58 | -------------------------------------------------------------------------------- /examples/slate-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/slate-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "react": "^16.12.0", 4 | "react-dom": "^16.12.0", 5 | "slate": "^0.57.1", 6 | "slate-history": "^0.57.1", 7 | "slate-react": "^0.57.1" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.8.4", 11 | "@babel/plugin-proposal-class-properties": "^7.8.3", 12 | "parcel": "^1.12.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/things-to-share/Readme.md: -------------------------------------------------------------------------------- 1 | # Developing: 2 | 3 | If you want to run against the prod data, then `cd client && yarn start` will do it. 4 | Otherwise, `cd server && yarn start`, and change `client/src/index.js` to point to `localhost:9090`. 5 | 6 | # Deploying 7 | For server changes: `cd server && yarn run deploy`. Then in the glitch terminal, `git merge updates` 8 | For client changes: `cd client && yarn run deploy` 9 | 10 | -------------------------------------------------------------------------------- /examples/things-to-share/client/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/*.js 2 | -------------------------------------------------------------------------------- /examples/things-to-share/client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-unused-vars": [ 4 | "warn", 5 | { "vars": "local", "args": "none", "ignoreRestSiblings": true } 6 | ], 7 | 8 | "react/jsx-uses-react": "warn", 9 | "react/jsx-uses-vars": "warn" 10 | }, 11 | "plugins": ["react"], 12 | 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | } 17 | }, 18 | "parser": "babel-eslint" 19 | } 20 | -------------------------------------------------------------------------------- /examples/things-to-share/client/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/bower\.json 3 | .*/config\.json 4 | 5 | [include] 6 | ../../../packages 7 | ../shared 8 | 9 | [libs] 10 | 11 | [lints] 12 | sketchy-null=error 13 | sketchy-null-bool=off 14 | 15 | [options] 16 | 17 | [strict] 18 | -------------------------------------------------------------------------------- /examples/things-to-share/client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /examples/things-to-share/client/.workbox-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | importScripts: [], 3 | globDirectory: './dist', 4 | globPatterns: [ 5 | '**/*.{css,html,js,gif,ico,jpg,png,svg,webp,woff,woff2,ttf,otf,eot,webmanifest,manifest}', 6 | ], 7 | runtimeCaching: [], 8 | ignoreURLParametersMatching: [/.*/], 9 | offlineGoogleAnalytics: false, 10 | }; 11 | -------------------------------------------------------------------------------- /examples/things-to-share/client/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/things-to-share/client/icons/favicon.png -------------------------------------------------------------------------------- /examples/things-to-share/client/icons/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/things-to-share/client/icons/icon192.png -------------------------------------------------------------------------------- /examples/things-to-share/client/icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/things-to-share/client/icons/icon512.png -------------------------------------------------------------------------------- /examples/things-to-share/client/icons/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/things-to-share/client/icons/maskable_icon.png -------------------------------------------------------------------------------- /examples/things-to-share/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Things to Share 7 | 22 | 23 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /examples/things-to-share/client/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Things to Share", 3 | "name": "Things to Share", 4 | "description": "Collect links to funny/interesting things", 5 | "start_url": "/?source=pwa", 6 | "icons": [ 7 | { 8 | "src": "/icons/icon512.png", 9 | "type": "image/png", 10 | "sizes": "512x512" 11 | }, 12 | { 13 | "src": "/icons/icon192.png", 14 | "type": "image/png", 15 | "sizes": "192x192" 16 | }, 17 | { 18 | "src": "/icons/maskable_icon.png", 19 | "type": "image/png", 20 | "sizes": "512x512", 21 | "purpose": "maskable" 22 | } 23 | ], 24 | "background_color": "#9c27b0", 25 | "display": "standalone", 26 | "scope": "/", 27 | "theme_color": "#9c27b0", 28 | "share_target": { 29 | "action": "/?add", 30 | "method": "GET", 31 | "enctype": "application/x-www-form-urlencoded", 32 | "params": { 33 | "title": "title", 34 | "text": "text", 35 | "url": "url" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/things-to-share/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "things-to-share-client", 3 | "private": true, 4 | "version": "1.0.0", 5 | "dependencies": { 6 | "@birchill/json-equalish": "^1.1.0", 7 | "@emotion/core": "^10.0.28", 8 | "@material-ui/core": "^4.9.9", 9 | "@material-ui/icons": "^4.9.1", 10 | "@material-ui/lab": "^4.0.0-alpha.49", 11 | "@types/react": "^16.8.6", 12 | "broadcast-channel": "^3.0.3", 13 | "gravatar-url": "^3.1.0", 14 | "he": "^1.2.0", 15 | "idb": "^5.0.1", 16 | "pako": "^1.0.11", 17 | "parse-color": "^1.0.0", 18 | "querystring": "^0.2.0", 19 | "react": "^16.8.0", 20 | "react-dom": "^16.8.0", 21 | "react-spring": "^8.0.27", 22 | "react-use-measure": "^2.0.0", 23 | "typeface-roboto": "^0.0.75" 24 | }, 25 | "scripts": { 26 | "deploy": "npm run build && cd dist && surge . things-to-share.surge.sh", 27 | "build": "parcel build index.html", 28 | "start": "parcel index.html --port=1235" 29 | }, 30 | "browserslist": [ 31 | "last 1 Chrome versions" 32 | ], 33 | "devDependencies": { 34 | "@babel/core": "^7.0.0-0", 35 | "@babel/plugin-proposal-class-properties": "^7.8.3", 36 | "@babel/register": "^7.8.3", 37 | "babel-eslint": "^10.1.0", 38 | "eslint": "^6.8.0", 39 | "eslint-plugin-react": "^7.19.0", 40 | "fast-deep-equal": "^3.1.1", 41 | "flow-bin": "^0.116.1", 42 | "parcel": "^1.12.4", 43 | "parcel-plugin-workbox-cache": "^2.0.0", 44 | "prettier": "^2.0.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/things-to-share/client/src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { render } from 'react-dom'; 3 | import React from 'react'; 4 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; 5 | 6 | import 'typeface-roboto'; 7 | 8 | import CssBaseline from '@material-ui/core/CssBaseline'; 9 | 10 | import Auth from './Auth'; 11 | import App from './App'; 12 | 13 | const darkTheme = createMuiTheme({ 14 | palette: { 15 | type: 'dark', 16 | 17 | primary: { main: '#9c27b0' }, 18 | secondary: { 19 | main: '#00b0ff', 20 | }, 21 | }, 22 | }); 23 | 24 | const node = document.createElement('div'); 25 | if (document.body) { 26 | document.body.appendChild(node); 27 | } 28 | // const host = 'localhost:9090'; 29 | const host = 'things-to-share.glitch.me'; 30 | window.addEventListener('load', () => { 31 | render( 32 | 33 | 34 | ( 37 | 38 | )} 39 | /> 40 | , 41 | node, 42 | ); 43 | }); 44 | -------------------------------------------------------------------------------- /examples/things-to-share/client/src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { type Schema } from '../../../../packages/client-bundle'; 4 | 5 | export type TagT = { 6 | id: string, 7 | title: string, 8 | color: string, 9 | }; 10 | 11 | export const TagSchema: Schema = { 12 | type: 'object', 13 | attributes: { 14 | id: 'string', 15 | title: 'string', 16 | color: 'string', 17 | }, 18 | }; 19 | 20 | export type CategoryT = { 21 | id: string, 22 | title: string, 23 | }; 24 | 25 | export const CategorySchema: Schema = { 26 | type: 'object', 27 | attributes: { 28 | id: 'string', 29 | title: 'string', 30 | }, 31 | }; 32 | 33 | export type LinkT = { 34 | id: string, 35 | url: string, 36 | fetchedContent: mixed, 37 | tags: { [key: string]: boolean }, 38 | description: mixed, 39 | completed: ?number, 40 | added: number, 41 | }; 42 | 43 | export const LinkSchema: Schema = { 44 | type: 'object', 45 | attributes: { 46 | id: 'string', 47 | url: 'string', 48 | fetchedContent: 'any', 49 | tags: { type: 'map', value: 'boolean' }, 50 | description: 'any', 51 | completed: { type: 'optional', value: 'number' }, 52 | added: 'number', 53 | }, 54 | }; 55 | 56 | // export type Comment 57 | // export type Reaction = { 58 | // id: string, 59 | // } 60 | 61 | const colorsRaw = 62 | '1f77b4ff7f0e2ca02cd627289467bd8c564be377c27f7f7fbcbd2217becf'; 63 | export const colors = []; 64 | for (let i = 0; i < colorsRaw.length; i += 6) { 65 | colors.push('#' + colorsRaw.slice(i, i + 6)); 66 | } 67 | -------------------------------------------------------------------------------- /examples/things-to-share/server/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/bower\.json 3 | .*/config\.json 4 | 5 | [include] 6 | ../../../packages 7 | ../client/src/types.js 8 | 9 | [libs] 10 | 11 | [lints] 12 | sketchy-null=error 13 | sketchy-null-bool=off 14 | 15 | [options] 16 | 17 | [strict] 18 | -------------------------------------------------------------------------------- /examples/things-to-share/server/.gitignore: -------------------------------------------------------------------------------- 1 | importable.json 2 | parsed.json 3 | .data 4 | -------------------------------------------------------------------------------- /examples/things-to-share/server/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | }); 8 | 9 | const { serverForUser, crdtImpl } = require('../../../packages/server-bundle/full.js'); 10 | const fs = require('fs'); 11 | const path = require('path'); 12 | const { getSchemaChecker } = require('./getSchema'); 13 | 14 | const cliSession = `_cli_${Date.now()}`; 15 | 16 | const { LinkSchema } = require('../client/src/types'); 17 | const hlc = require('../../../packages/hybrid-logical-clock'); 18 | 19 | const commands = { 20 | import: ([userId, collection, fileName]) => { 21 | if (!userId || !collection || !fileName) { 22 | console.error(`Usage: import userId collectionName fileName`); 23 | process.exit(1); 24 | } 25 | 26 | const session = `_cli_${Date.now()}`; 27 | let clock = hlc.init(session, Date.now()); 28 | const getStamp = () => { 29 | clock = hlc.inc(clock, Date.now()); 30 | return hlc.pack(clock); 31 | }; 32 | 33 | const server = serverForUser(path.join(__dirname, '.data'), userId, getSchemaChecker); 34 | const data = JSON.parse(fs.readFileSync(fileName, 'utf8')); 35 | server.persistence.addDeltas( 36 | collection, 37 | cliSession, 38 | data.map(data => ({ 39 | node: data.id, 40 | delta: { 41 | type: 'set', 42 | path: [], 43 | value: crdtImpl.createWithSchema(data, getStamp(), getStamp, LinkSchema), 44 | }, 45 | })), 46 | ); 47 | }, 48 | }; 49 | 50 | const [_, __, cmd, ...opts] = process.argv; 51 | if (!commands[cmd]) { 52 | console.warn(`Usage: cli.js [cmd]`); 53 | Object.keys(commands).forEach(cmd => { 54 | console.log(`- ${cmd}`); 55 | }); 56 | process.exit(1); 57 | } 58 | commands[cmd](opts); 59 | -------------------------------------------------------------------------------- /examples/things-to-share/server/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | cd $DIR 4 | 5 | cd ../../../ 6 | (cd public/things-to-share-server && git pull origin master) 7 | node pack 8 | cd public/things-to-share-server 9 | git commit -am 'update' 10 | git push origin master:update -------------------------------------------------------------------------------- /examples/things-to-share/server/getSchema.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { TagSchema, LinkSchema } from '../client/src/types'; 4 | import { validateDelta } from '../../../packages/nested-object-crdt/src/schema'; 5 | 6 | export const getSchemaChecker = (colid: string) => 7 | ({ 8 | tags: delta => validateDelta(TagSchema, delta), 9 | links: delta => validateDelta(LinkSchema, delta), 10 | }[colid]); 11 | -------------------------------------------------------------------------------- /examples/things-to-share/server/glitch.js: -------------------------------------------------------------------------------- 1 | require('regenerator-runtime'); 2 | const { run } = require('../../../packages/server-bundle/full.js'); 3 | const { addProxy } = require('./'); 4 | const dataPath = '.data'; 5 | const port = process.env.PORT || 9090; 6 | const { getSchemaChecker } = require('./getSchema'); 7 | const result = run(dataPath, getSchemaChecker, port); 8 | addProxy(result.app); 9 | console.log('listening on ' + port); 10 | -------------------------------------------------------------------------------- /examples/things-to-share/server/import.js: -------------------------------------------------------------------------------- 1 | const { getTwoLevels } = require('./open-graph.js'); 2 | const fs = require('fs'); 3 | 4 | const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); 5 | 6 | var rx = /https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; 7 | 8 | const run = async () => { 9 | const news = ( 10 | await Promise.all( 11 | data.children.map(async child => { 12 | const match = child.content.match(rx); 13 | if (!match) { 14 | console.log('no url', child.content, child.children.length); 15 | return; 16 | } 17 | const url = match[0]; 18 | const without = child.content.replace(url, '').trim(); 19 | const description = without.length ? without : null; 20 | 21 | const data = await getTwoLevels(url); 22 | 23 | return { 24 | fetchedContent: data, 25 | url, 26 | description, 27 | completed: child.completed, 28 | added: child.created 29 | }; 30 | }) 31 | ) 32 | ).filter(Boolean); 33 | fs.writeFileSync('./parsed.json', JSON.stringify(news, null, 2)); 34 | console.log(news.length); 35 | }; 36 | run().catch(console.error); 37 | -------------------------------------------------------------------------------- /examples/things-to-share/server/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import 'regenerator-runtime'; 4 | import { parse } from 'node-html-parser'; 5 | const fetch = require('node-fetch'); 6 | const { getTwoLevels } = require('./open-graph.js'); 7 | 8 | export const addProxy = (app: { get: (string, (any, any) => void) => void }) => { 9 | app.get('/proxy/info', (req, res) => { 10 | if (!req.query.url) { 11 | return res.status(400).end(); 12 | } 13 | getTwoLevels(req.query.url).then( 14 | ogs => { 15 | res.json(ogs); 16 | }, 17 | err => { 18 | res.status(500).json({ failed: true, error: err.message }); 19 | }, 20 | ); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /examples/things-to-share/server/open-graph.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const { parse } = require('node-html-parser'); 3 | 4 | const rx = /https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)?/gi; 5 | 6 | const getTwoLevels = async url => { 7 | const data = await getGraphData(url); 8 | if (data && data['og:description']) { 9 | let mainDesc = data['og:description'][0].trim(); 10 | if (mainDesc.startsWith('“') && mainDesc.endsWith('”')) { 11 | mainDesc = mainDesc.replace(/”\s*$/, '').replace(/^\s*“/, ''); 12 | data['og:description'][0] = mainDesc; 13 | } 14 | const innerUrl = mainDesc.match(rx); 15 | if (innerUrl) { 16 | const url = innerUrl[0]; 17 | const innerData = await getGraphData(url); 18 | if (innerData) { 19 | if (!innerData['og:url'] || innerData['og:url'][0] !== data['og:url'][0]) { 20 | data['embedded'] = innerData; 21 | const without = mainDesc.replace(url, '').trim(); 22 | // if it was at the end, we can take it off 23 | if (mainDesc.startsWith(without)) { 24 | data['og:description'][0] = without; 25 | } 26 | } 27 | } 28 | } 29 | } 30 | return data; 31 | }; 32 | 33 | const getGraphData = async (url, cb) => { 34 | try { 35 | const res = await fetch(url, { 36 | headers: { 37 | 'User-Agent': 38 | 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)', 39 | }, 40 | }); 41 | if (!res.headers.get('Content-type').startsWith('text/html')) { 42 | return null; 43 | } 44 | const text = await res.text(); 45 | const parsed = parse(text); 46 | console.log('parsed', text.length); 47 | const ogs = {}; 48 | parsed.querySelectorAll('meta').forEach(meta => { 49 | const property = meta.getAttribute('property'); 50 | if (property && property.startsWith('og:')) { 51 | const content = meta.getAttribute('content'); 52 | if (!ogs[property]) { 53 | ogs[property] = [content]; 54 | } else { 55 | ogs[property].push(content); 56 | } 57 | } 58 | }); 59 | return ogs; 60 | } catch (_err) { 61 | return null; 62 | } 63 | }; 64 | 65 | module.exports = { getGraphData, getTwoLevels }; 66 | -------------------------------------------------------------------------------- /examples/things-to-share/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.9.0", 4 | "@babel/plugin-proposal-class-properties": "^7.8.3", 5 | "@babel/preset-env": "^7.9.0", 6 | "@babel/preset-flow": "^7.9.0", 7 | "@babel/register": "^7.9.0", 8 | "flow-bin": "^0.123.0" 9 | }, 10 | "dependencies": { 11 | "better-sqlite3": "^6.0.1", 12 | "node-fetch": "^2.6.0", 13 | "node-html-parser": "^1.2.16", 14 | "open-graph-scraper": "^3.6.2" 15 | }, 16 | "scripts": { 17 | "deploy": "./deploy.sh", 18 | "start": "node ./run.js" 19 | }, 20 | "engines": { 21 | "node": "12.x" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/things-to-share/server/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'], 7 | }); 8 | const { run } = require('../../../packages/server-bundle/full.js'); 9 | const { addProxy } = require('./'); 10 | const dataPath = __dirname + '/.data'; 11 | const port = process.env.PORT != null ? parseInt(process.env.PORT) : 9090; 12 | const { getSchemaChecker } = require('./getSchema'); 13 | const result = run(dataPath, getSchemaChecker, port); 14 | addProxy(result.app); 15 | console.log('listening on ' + port); 16 | -------------------------------------------------------------------------------- /examples/things-to-share/server/translate.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'] 7 | }); 8 | 9 | const hlc = require('../../../packages/hybrid-logical-clock'); 10 | const fs = require('fs'); 11 | 12 | const data = require('./parsed.json'); 13 | 14 | let clock = hlc.init(`_cli_${Date.now()}`, Date.now()); 15 | const getStamp = () => { 16 | clock = hlc.inc(clock, Date.now()); 17 | return hlc.pack(clock); 18 | }; 19 | 20 | fs.writeFileSync( 21 | 'importable.json', 22 | JSON.stringify( 23 | data.map(({ fetchedContent, url, description, completed, added }) => ({ 24 | id: getStamp(), 25 | url, 26 | fetchedContent, 27 | tags: {}, 28 | description: description ? [{ insert: description }] : null, 29 | completed, 30 | added 31 | })), 32 | null, 33 | 2 34 | ) 35 | ); 36 | -------------------------------------------------------------------------------- /examples/tree-notes/.nvmrc: -------------------------------------------------------------------------------- 1 | 13 2 | -------------------------------------------------------------------------------- /examples/tree-notes/Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # Running 3 | Expects to be pointed to a server running `examples/general-server` 4 | 5 | # Next steps 6 | 7 | - [ ] backspace to delete 8 | - [ ] backspace to merge? might not be anything we can do about late-coming changes to the node that got deleted... 😞 9 | - [ ] actual drag and drop my folks 10 | - [ ] multiple files up in here! yes we need it. how we do it? multiple databases, obvs. 11 | -------------------------------------------------------------------------------- /examples/tree-notes/collections.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const ItemSchema = { 4 | type: 'object', 5 | attributes: { 6 | id: 'string', 7 | author: 'string', 8 | body: 'rich-text', 9 | tags: { type: 'map', value: 'number' }, 10 | createdDate: 'number', 11 | completed: { type: 'optional', value: 'number' }, 12 | 13 | children: 'id-array', 14 | 15 | columnData: { type: 'map', value: 'rich-text' }, 16 | childColumnConfig: { 17 | type: 'optional', 18 | value: { 19 | type: 'object', 20 | attributes: { 21 | recursive: 'boolean', 22 | columns: { 23 | type: 'map', 24 | value: { 25 | type: 'object', 26 | attributes: { 27 | title: 'string', 28 | kind: 'string', 29 | width: { type: 'optional', value: 'number' }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | 37 | // hmk right? 38 | 39 | style: 'string', // header | code | quote | todo 40 | theme: 'string', // list | blog | whiteboard | mindmap 41 | numbering: { 42 | type: 'optional', 43 | value: { 44 | type: 'object', 45 | attributes: { 46 | style: 'string', 47 | startWith: { type: 'optional', value: 'number' }, 48 | }, 49 | }, 50 | }, // {style: numbers | letters | roman, startWith?: number} 51 | 52 | trashed: { type: 'optional', value: 'number' }, 53 | // {[reaction-name]: {[userid]: date}} 54 | reactions: { type: 'map', value: { type: 'map', value: 'number' } }, 55 | }, 56 | }; 57 | 58 | /*:: 59 | import type {CRDT} from '../../packages/rich-text-crdt' 60 | export type ItemT = { 61 | id: string, 62 | author: string, 63 | body: CRDT, 64 | tags: {[key: string]: number}, 65 | createdDate: number, 66 | completed?: number, 67 | 68 | columnData: {[colId: string]: CRDT}, 69 | childColumnConfig: ?{ 70 | columns: {[colId: string]: { 71 | title: string, 72 | kind: string, 73 | width?: number, 74 | }}, 75 | recursive: boolean, 76 | }, 77 | children: Array, 78 | 79 | style: string, 80 | theme: string, 81 | numbering: ?{ 82 | style: string, 83 | startWith?: number, 84 | }, 85 | 86 | trashed?: number, 87 | reactions: {[reactionId: string]: {[userId: string]: number}} 88 | } 89 | */ 90 | 91 | const schemas = { 92 | items: ItemSchema, 93 | }; 94 | 95 | module.exports = schemas; 96 | -------------------------------------------------------------------------------- /examples/tree-notes/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/tree-notes/icons/favicon.png -------------------------------------------------------------------------------- /examples/tree-notes/icons/icon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/tree-notes/icons/icon192.png -------------------------------------------------------------------------------- /examples/tree-notes/icons/icon196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/tree-notes/icons/icon196.png -------------------------------------------------------------------------------- /examples/tree-notes/icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/tree-notes/icons/icon512.png -------------------------------------------------------------------------------- /examples/tree-notes/icons/maskable_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/examples/tree-notes/icons/maskable_icon.png -------------------------------------------------------------------------------- /examples/tree-notes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Tree Notes 9 | 38 | 39 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /examples/tree-notes/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Notablemind", 3 | "name": "Notablemind", 4 | "description": "Take a lot of notes n stuff", 5 | "start_url": "/?source=pwa", 6 | "icons": [ 7 | { 8 | "src": "/icons/icon512.png", 9 | "type": "image/png", 10 | "sizes": "512x512" 11 | }, 12 | { 13 | "src": "/icons/icon192.png", 14 | "type": "image/png", 15 | "sizes": "192x192" 16 | }, 17 | { 18 | "src": "/icons/maskable_icon.png", 19 | "type": "image/png", 20 | "sizes": "192x192", 21 | "purpose": "maskable" 22 | } 23 | ], 24 | "background_color": "#2196f3", 25 | "display": "standalone", 26 | "scope": "/", 27 | "theme_color": "#2196f3" 28 | } 29 | -------------------------------------------------------------------------------- /examples/tree-notes/src/SearchPage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import { Jump } from './JumpDialog'; 4 | 5 | const Search = ({ client, url }: *) => { 6 | return {}} />; 7 | }; 8 | 9 | export default Search; 10 | -------------------------------------------------------------------------------- /examples/tree-notes/src/run.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import 'regenerator-runtime/runtime'; 3 | import run from './'; 4 | 5 | // import('./').then(({ default: run }) => run()); 6 | run(); 7 | -------------------------------------------------------------------------------- /examples/tree-notes/src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { type ItemT } from '../collections'; 3 | import * as rich from '../../../packages/rich-text-crdt/'; 4 | const blankBody = (text) => { 5 | const crdt = rich.init(); 6 | return rich.apply(crdt, rich.insert(crdt, ':root:', 0, text + '\n')); 7 | }; 8 | export const blankItem = (text: string = ''): ItemT => ({ 9 | id: '', 10 | author: '', 11 | body: blankBody(text), 12 | tags: {}, 13 | createdDate: Date.now(), 14 | columnData: {}, 15 | childColumnConfig: null, 16 | style: 'normal', 17 | theme: 'list', 18 | numbering: null, 19 | reactions: {}, 20 | children: [], 21 | }); 22 | -------------------------------------------------------------------------------- /examples/visualize/compare.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/visualize/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/visualize/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "automerge": "^0.13.0", 4 | "d3": "^5.15.0", 5 | "parcel": "^1.12.4", 6 | "prosemirror": "^0.11.1", 7 | "prosemirror-collab": "^1.2.2", 8 | "prosemirror-example-setup": "^1.1.2", 9 | "prosemirror-keymap": "^1.1.3", 10 | "prosemirror-model": "^1.9.1", 11 | "prosemirror-schema-basic": "^1.1.2", 12 | "prosemirror-state": "^1.3.2", 13 | "prosemirror-view": "^1.14.2", 14 | "quill": "^1.3.7", 15 | "quill-cursors": "https://github.com/jaredly/quill-cursors#quill-cursors-v3.0.2-gitpkg", 16 | "y-prosemirror": "^0.3.2", 17 | "y-protocols": "^0.2.0", 18 | "y-quill": "^0.0.2", 19 | "yjs": "^13.0.4" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.8.4", 23 | "@babel/plugin-proposal-class-properties": "^7.8.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/visualize/prose-collab.js: -------------------------------------------------------------------------------- 1 | import { EditorState } from 'prosemirror-state'; 2 | import { EditorView } from 'prosemirror-view'; 3 | import { schema } from 'prosemirror-schema-basic'; 4 | import * as collab from 'prosemirror-collab'; 5 | import { exampleSetup } from 'prosemirror-example-setup'; 6 | 7 | function collabEditor(place) { 8 | let view = new EditorView(place, { 9 | state: EditorState.create({ 10 | schema, 11 | // doc: authority.doc, 12 | plugins: [collab.collab({ version: 0 })].concat( 13 | exampleSetup({ schema }), 14 | ), 15 | }), 16 | dispatchTransaction(transaction) { 17 | let newState = view.state.apply(transaction); 18 | view.updateState(newState); 19 | let sendable = collab.sendableSteps(newState); 20 | if (sendable) console.log(sendable); 21 | console.log('state', newState); 22 | // authority.receiveSteps(sendable.version, sendable.steps, 23 | // sendable.clientID) 24 | }, 25 | }); 26 | 27 | // authority.onNewSteps.push(function() { 28 | // let newData = authority.stepsSince(collab.getVersion(view.state)) 29 | // view.dispatch( 30 | // collab.receiveTransaction(view.state, newData.steps, newData.clientIDs)) 31 | // }) 32 | 33 | return view; 34 | } 35 | 36 | const editorContainer = document.createElement('div'); 37 | document.body.appendChild(editorContainer); 38 | collabEditor(editorContainer); 39 | -------------------------------------------------------------------------------- /examples/visualize/prosemirror.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/whiteboard/client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-flow", "@babel/preset-env"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"], 4 | "ignore": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/whiteboard/client/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../../packages 5 | 6 | [libs] 7 | 8 | [lints] 9 | sketchy-null=error 10 | sketchy-null-bool=off 11 | 12 | [options] 13 | 14 | [strict] 15 | -------------------------------------------------------------------------------- /examples/whiteboard/client/Readme.md: -------------------------------------------------------------------------------- 1 | # Offline . Online . Etc. story 2 | 3 | Scenarios: 4 | - app first-run, but no backend (either no backend chosen) 5 | - probably show a screen that's like "login / or continue without login / or select a different server" 6 | - the signup screen should do a query to the selected server to see if they require an invite code 7 | - indicate that you can start your own server w/ glitch 8 | - app w/ data & login ; show indication of connectivity status 9 | - app w/ data -- switching backend providers; need a way to connect to a backend w/o any shared history. 10 | 11 | Should have a much better welcome screen. 12 | -------------------------------------------------------------------------------- /examples/whiteboard/client/Styles.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const Colors = { 4 | lightPink: '#faeaff', 5 | pink: '#f3d0ff', 6 | darkPink: '#e7a2ff', 7 | darkestPink: '#ca31ff', 8 | offBlack: '#3a004e', 9 | focusBlue: '#5af', 10 | }; 11 | -------------------------------------------------------------------------------- /examples/whiteboard/client/defaults.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { type pos, type rect, type CardT, colors } from './types'; 4 | 5 | export const DEFAULT_MARGIN = 12; 6 | export const DEFAULT_HEIGHT = 100; 7 | export const DEFAULT_WIDTH = 200; 8 | 9 | const defaultCards = require('./data.json'); 10 | 11 | const shuffle = (array) => { 12 | return array 13 | .map((item) => [Math.random(), item]) 14 | .sort((a, b) => a[0] - b[0]) 15 | .map((item) => item[1]); 16 | }; 17 | 18 | export const makeDefaultCards = (genId: () => string): Array => { 19 | return shuffle(defaultCards).map(({ description, title }, i): CardT => ({ 20 | id: genId(), 21 | title, 22 | description, 23 | disabled: false, 24 | })); 25 | }; 26 | -------------------------------------------------------------------------------- /examples/whiteboard/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/whiteboard/client/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /** @jsx jsx */ 3 | import { jsx } from '@emotion/core'; 4 | import { render } from 'react-dom'; 5 | import React from 'react'; 6 | import { 7 | createInMemoryDeltaClient, 8 | createPersistedDeltaClient, 9 | createPersistedBlobClient, 10 | } from '../../../packages/client-bundle'; 11 | import { default as makeDeltaInMemoryPersistence } from '../../../packages/idb/src/delta-mem'; 12 | 13 | import { SortSchema, CommentSchema, CardSchema } from './types'; 14 | 15 | import Main from './Main'; 16 | import Auth, { useAuthStatus, logout } from './Auth'; 17 | 18 | const schemas = { 19 | cards: CardSchema, 20 | comments: CommentSchema, 21 | sorts: SortSchema, 22 | }; 23 | 24 | const AppWithAuth = ({ host }) => { 25 | const status = useAuthStatus(host); 26 | if (status === null) { 27 | console.log('waiting'); 28 | // Waiting 29 | return
; 30 | } else if (status === false) { 31 | console.log('need login'); 32 | return ; 33 | } else { 34 | console.log('now working'); 35 | return logout(host, status.token)} />; 36 | } 37 | }; 38 | 39 | const App = ({ 40 | host, 41 | auth, 42 | logout, 43 | }: { 44 | host: string, 45 | auth: ?{ token: string, user: { name: string, email: string } }, 46 | logout: () => mixed, 47 | }) => { 48 | // We're assuming we're authed, and cookies are taking care of things. 49 | const client = React.useMemo(() => { 50 | console.log('starting a client', auth); 51 | return auth 52 | ? createPersistedDeltaClient( 53 | 'value-sort', 54 | schemas, 55 | `${host.startsWith('localhost:') ? 'ws' : 'wss'}://${host}/sync?token=${ 56 | auth.token 57 | }`, 58 | ) 59 | : createPersistedBlobClient('miller-values-sort', schemas, null, 2); 60 | }, [auth && auth.token]); 61 | return
; 62 | }; 63 | 64 | const root = document.createElement('div'); 65 | if (document.body) { 66 | document.body.appendChild(root); 67 | // const host = 'localhost:9090'; 68 | const host = 'value-sort-server.glitch.me'; 69 | console.log('toplevel login'); 70 | render(, root); 71 | } 72 | -------------------------------------------------------------------------------- /examples/whiteboard/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whiteboard-example-client", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@birchill/json-equalish": "^1.1.0", 6 | "@emotion/core": "^10.0.28", 7 | "alea": "^1.0.0", 8 | "broadcast-channel": "^3.0.3", 9 | "gravatar-url": "^3.1.0", 10 | "idb": "^5.0.1", 11 | "parcel": "^1.12.4", 12 | "parse-color": "^1.0.0", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-spring": "9.0.0-canary.808.14.192785c", 16 | "react-use-gesture": "^7.0.9" 17 | }, 18 | "scripts": { 19 | "deploy": "parcel build index.html && cd dist && surge . value-sort.surge.sh" 20 | }, 21 | "browserslist": [ 22 | "last 1 Chrome versions" 23 | ], 24 | "devDependencies": { 25 | "@babel/core": "^7.0.0-0", 26 | "@babel/plugin-proposal-class-properties": "^7.8.3", 27 | "@babel/register": "^7.8.3", 28 | "fast-deep-equal": "^3.1.1", 29 | "flow-bin": "^0.116.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/whiteboard/client/process.py: -------------------------------------------------------------------------------- 1 | import json 2 | text = open('./uihi.txt').read().split('\n')[1:] 3 | 4 | repeated = text[0:4] 5 | 6 | cards = [] 7 | pending = [] 8 | def make_card(pending): 9 | return {'title': pending[0], 'description': ' '.join(pending[1:])} 10 | 11 | for line in text[9:]: 12 | if line in repeated: continue 13 | if line.endswith(':'): 14 | if len(pending): 15 | cards.append(make_card(pending)) 16 | pending = [line[:-1]] 17 | else: 18 | pending.append(line) 19 | if len(pending): 20 | cards.append(make_card(pending)) 21 | 22 | open('./data.json', 'w').write(json.dumps(cards, indent=2)) 23 | 24 | -------------------------------------------------------------------------------- /examples/whiteboard/client/screens/Piles/EditableTitle.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | /* @jsx jsx */ 3 | import { jsx } from '@emotion/core'; 4 | import React from 'react'; 5 | 6 | const EditableTitle = ({ title, onChange }: { title: string, onChange: (string) => mixed }) => { 7 | const [wip, setWip] = React.useState(null); 8 | if (wip != null) { 9 | return ( 10 |
11 | setWip(evt.target.value)} 14 | onKeyDown={(evt) => { 15 | if (evt.key === 'Enter' && wip.trim() != '' && wip !== title) { 16 | onChange(wip); 17 | setWip(null); 18 | } 19 | }} 20 | onBlur={() => setWip(null)} 21 | css={styles.titleInput} 22 | autoFocus 23 | /> 24 |
25 | ); 26 | } 27 | return ( 28 |
{ 30 | setWip(title); 31 | }} 32 | css={{ fontSize: 32 }} 33 | > 34 | {title} 35 |
36 | ); 37 | }; 38 | 39 | const styles = { 40 | titleInput: { 41 | fontSize: 32, 42 | padding: 0, 43 | fontWeight: 'inherit', 44 | border: 'none', 45 | textAlign: 'center', 46 | }, 47 | }; 48 | 49 | export default EditableTitle; 50 | -------------------------------------------------------------------------------- /examples/whiteboard/client/screens/Piles/consts.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export const CARD_WIDTH = 200; 3 | export const PILE_WIDTH = CARD_WIDTH * 1.5; 4 | export const CARD_HEIGHT = 100; 5 | export const PILE_HEIGHT = CARD_HEIGHT * 2; 6 | -------------------------------------------------------------------------------- /examples/whiteboard/client/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const atMorning = (d) => { 4 | d.setHours(0, 0, 0, 0); 5 | return d; 6 | }; 7 | 8 | export const relativeTime = (time: number) => { 9 | const now = Date.now(); 10 | const thisMorning = atMorning(new Date()); 11 | const yesterdayMorning = atMorning(new Date(thisMorning.getTime() - 3600 * 1000)); 12 | if (time > thisMorning.getTime()) { 13 | return new Date(time).toLocaleTimeString(); 14 | } 15 | if (time > yesterdayMorning.getTime()) { 16 | return 'Yesterday, ' + new Date(time).toLocaleTimeString(); 17 | } 18 | return new Date(time).toLocaleDateString(); 19 | }; 20 | -------------------------------------------------------------------------------- /examples/whiteboard/client/whiteboard/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type pos = {| 4 | x: number, 5 | y: number, 6 | |}; 7 | export type rect = { position: pos, size: pos }; 8 | 9 | export const clamp = (pos: pos, size: pos, range: rect) => { 10 | return { 11 | x: Math.min(Math.max(range.position.x, pos.x), range.position.x + range.size.x - size.x), 12 | y: Math.min(Math.max(range.position.y, pos.y), range.position.y + range.size.y - size.y), 13 | }; 14 | }; 15 | 16 | export const toScreen = (pos: pos, pan: pos, zoom: number) => { 17 | return { x: (pos.x + pan.x) * zoom, y: (pos.y + pan.y) * zoom }; 18 | }; 19 | 20 | export const fromScreen = (pos: pos, pan: pos, zoom: number) => { 21 | return { x: pos.x / zoom + pan.x, y: pos.y / zoom + pan.y }; 22 | }; 23 | 24 | export const addPos = (pos1: pos, pos2: pos) => ({ 25 | x: pos1.x + pos2.x, 26 | y: pos1.y + pos2.y, 27 | }); 28 | export const posDiff = (p1: pos, p2: pos) => ({ 29 | x: p2.x - p1.x, 30 | y: p2.y - p1.y, 31 | }); 32 | export const absMax = (pos: pos) => Math.max(Math.abs(pos.x), Math.abs(pos.y)); 33 | export const normalizedRect = ({ position, size }: rect): rect => ({ 34 | position: { 35 | x: size.x < 0 ? position.x + size.x : position.x, 36 | y: size.y < 0 ? position.y + size.y : position.y, 37 | }, 38 | size: { 39 | x: Math.abs(size.x), 40 | y: Math.abs(size.y), 41 | }, 42 | }); 43 | 44 | export const evtPos = (evt: { clientX: number, clientY: number }): pos => ({ 45 | x: evt.clientX, 46 | y: evt.clientY, 47 | }); 48 | 49 | export const rectIntersect = (one: rect, two: rect) => { 50 | return ( 51 | ((two.position.x <= one.position.x && one.position.x <= two.position.x + two.size.x) || 52 | (one.position.x <= two.position.x && two.position.x <= one.position.x + one.size.x)) && 53 | ((two.position.y <= one.position.y && one.position.y <= two.position.y + two.size.y) || 54 | (one.position.y <= two.position.y && two.position.y <= one.position.y + one.size.y)) 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /examples/whiteboard/server/.gitignore: -------------------------------------------------------------------------------- 1 | .data 2 | -------------------------------------------------------------------------------- /examples/whiteboard/server/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 3 | cd $DIR 4 | 5 | cd ../../../ 6 | node pack 7 | cd public/whiteboard-server 8 | git commit -am 'update' 9 | git push origin master:update -------------------------------------------------------------------------------- /examples/whiteboard/server/glitch.js: -------------------------------------------------------------------------------- 1 | const { run } = require('../../../packages/server-bundle/full.js'); 2 | const dataPath = '.data'; 3 | const port = process.env.PORT || 9090; 4 | run(dataPath, port); 5 | console.log('listening on ' + port); 6 | -------------------------------------------------------------------------------- /examples/whiteboard/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.9.0", 4 | "@babel/plugin-proposal-class-properties": "^7.8.3", 5 | "@babel/preset-env": "^7.9.0", 6 | "@babel/preset-flow": "^7.9.0", 7 | "@babel/register": "^7.9.0" 8 | }, 9 | "dependencies": { 10 | "better-sqlite3": "^6.0.1" 11 | }, 12 | "scripts": { 13 | "deploy": "./deploy.sh" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/whiteboard/server/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @flow 3 | require('@babel/register')({ 4 | ignore: [/node_modules/], 5 | presets: ['@babel/preset-flow', '@babel/preset-env'], 6 | plugins: ['@babel/plugin-proposal-class-properties'] 7 | }); 8 | const { run } = require('../../../packages/server-bundle/full.js'); 9 | const dataPath = __dirname + '/.data'; 10 | const port = process.env.PORT || 9090; 11 | run(dataPath, port); 12 | console.log('listening on ' + port); 13 | -------------------------------------------------------------------------------- /pack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Ok folks 3 | // const fs = require('fs'); 4 | const pack = require('./packages/monorepo-pack'); 5 | 6 | const packages = [ 7 | // apps 8 | { 9 | name: 'planner', 10 | entry: 'examples/planner/server/glitch.js', 11 | dest: 'public/planner-server', 12 | start: true, 13 | }, 14 | { 15 | name: 'general-server', 16 | entry: ['examples/general-server/glitch.js', 'examples/general-server/glitch-admin.js'], 17 | dest: 'public/general-server', 18 | start: true, 19 | }, 20 | { 21 | name: 'things-to-share', 22 | entry: 'examples/things-to-share/server/glitch.js', 23 | dest: 'public/things-to-share-server', 24 | start: true, 25 | }, 26 | { 27 | name: 'whiteboard', 28 | entry: 'examples/whiteboard/server/glitch.js', 29 | dest: 'public/whiteboard-server', 30 | start: true, 31 | }, 32 | // libs 33 | { 34 | name: 'server-backup', 35 | entry: 'packages/server-backup/index.js', 36 | dest: 'public/server-backup', 37 | }, 38 | { 39 | name: 'server-bundle', 40 | entry: 'packages/server-bundle/full.js', 41 | dest: 'public/server-bundle', 42 | }, 43 | { 44 | name: 'client-bundle', 45 | entry: 'packages/client-bundle/index.js', 46 | dest: 'public/client-bundle', 47 | }, 48 | { 49 | name: 'rich-text-crdt', 50 | entry: 'packages/rich-text-crdt/index.js', 51 | dest: 'public/rich-text-crdt', 52 | }, 53 | { 54 | name: 'nested-object-crdt', 55 | entry: 'packages/nested-object-crdt/src/new.js', 56 | dest: 'public/nested-object-crdt', 57 | }, 58 | ]; 59 | 60 | const [_, __, arg] = process.argv; 61 | 62 | if (arg) { 63 | if ( 64 | !packages.some(package => { 65 | if (package.name === arg) { 66 | pack(package); 67 | return true; 68 | } 69 | }) 70 | ) { 71 | console.error(`No package named ${arg}`); 72 | } 73 | } else { 74 | packages.forEach(pack); 75 | } 76 | 77 | // pack({ 78 | // name: 'hybrid-logical-clock', 79 | // entry: 'packages/hybrid-logical-clock/src/index.js', 80 | // dest: 'public/hybrid-logical-clock', 81 | // }); 82 | 83 | // pack({ 84 | // name: 'local-first-bundle', 85 | // entry: 'packages/local-first-bundle/src/index.js', 86 | // dest: 'public/local-first-bundle', 87 | // }); 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@babel/cli": "^7.8.4", 5 | "@babel/core": "^7.9.0", 6 | "@babel/preset-env": "^7.12.11", 7 | "@babel/preset-flow": "^7.12.1", 8 | "@babel/preset-react": "^7.12.10", 9 | "@babel/register": "^7.9.0", 10 | "@testing-library/react": "^11.2.3", 11 | "flow-bin": "^0.118.0", 12 | "jest": "^24.9.0", 13 | "parcel": "^1.12.4", 14 | "prettier": "^1.19.1", 15 | "react": "^17.0.1", 16 | "react-dom": "^17.0.1" 17 | }, 18 | "// dependencies": { 19 | "body-parser": "^1.19.0", 20 | "express": "^4.17.1", 21 | "express-ws": "^4.0.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/auth/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /packages/auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "bcryptjs": "^2.4.3", 4 | "better-sqlite3": "^6.0.1", 5 | "jsonwebtoken": "^8.5.1" 6 | }, 7 | "devDependencies": { 8 | "flow-bin": "^0.121.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/client-bundle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/client-bundle", 3 | "dependencies": { 4 | "regenerator-runtime": "*" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/client-react/.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /packages/client-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "../../node_modules/.bin/jest" 4 | }, 5 | "devDependencies": { 6 | "@babel/plugin-proposal-export-default-from": "^7.12.1", 7 | "@testing-library/react-hooks": "^5.0.0", 8 | "react": "^16.13.1", 9 | "react-test-renderer": "^17.0.1" 10 | }, 11 | "jest": { 12 | "transformIgnorePatterns": [ 13 | "node_modules" 14 | ], 15 | "transform": { 16 | "\\.js$": [ 17 | "babel-jest", 18 | { 19 | "presets": [ 20 | "@babel/preset-env", 21 | "@babel/preset-flow" 22 | ], 23 | "ignore": [ 24 | "node_modules" 25 | ] 26 | } 27 | ] 28 | } 29 | }, 30 | "dependencies": { 31 | "fast-deep-equal": "^3.1.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/core/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-flow", "@babel/preset-env"], 3 | "ignore": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../nested-object-crdt 5 | ../hybrid-logical-clock 6 | ../rich-text-crdt 7 | ../idb 8 | 9 | [libs] 10 | flow-typed 11 | 12 | [lints] 13 | sketchy-null=error 14 | sketchy-null-bool=off 15 | 16 | [options] 17 | 18 | [strict] 19 | -------------------------------------------------------------------------------- /packages/core/.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | -------------------------------------------------------------------------------- /packages/core/Makefile: -------------------------------------------------------------------------------- 1 | 2 | FLOW=$(patsubst src/%.js,lib/%.js.flow,$(wildcard src/**/*.js src/*.js)) 3 | 4 | lib/%.js.flow: src/%.js 5 | cp $< $@ 6 | 7 | dirs: 8 | mkdir -p lib lib/blob lib/delta lib/multi 9 | 10 | build: dirs $(FLOW) 11 | yarn babel src/*.js -d lib 12 | yarn babel src/blob/*.js -d lib/blob 13 | yarn babel src/delta/*.js -d lib/delta 14 | yarn babel src/multi/*.js -d lib/multi 15 | 16 | .PHONY: build -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/core", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@birchill/json-equalish": "^1.1.0", 6 | "broadcast-channel": "^3.0.3", 7 | "fast-deep-equal": "^3.1.1" 8 | }, 9 | "browserslist": [ 10 | "last 1 Chrome versions" 11 | ], 12 | "scripts": { 13 | "test": "jest" 14 | }, 15 | "devDependencies": { 16 | "@babel/cli": "^7.8.3", 17 | "@babel/core": "^7.0.0-0", 18 | "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", 19 | "@babel/preset-env": ">7.9", 20 | "babel-jest": "^25.1.0", 21 | "flow-bin": "^0.116.1", 22 | "jest": "^25.1.0" 23 | }, 24 | "jest": { 25 | "transformIgnorePatterns": [ 26 | "node_modules" 27 | ], 28 | "transform": { 29 | "\\.js$": [ 30 | "babel-jest", 31 | { 32 | "presets": [ 33 | "@babel/preset-env", 34 | "@babel/preset-flow" 35 | ], 36 | "ignore": [ 37 | "node_modules" 38 | ] 39 | } 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/core/src/back-off.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const backOff = ( 4 | fn: () => Promise, 5 | wait: number = 200, 6 | rate: number = 1.5, 7 | initialWait: number = wait, 8 | ) => { 9 | fn() 10 | .catch(err => false) 11 | .then(succeeded => { 12 | if (succeeded) { 13 | return; 14 | // $FlowFixMe 15 | } else if (globalThis.document != undefined) { 16 | const tid = setTimeout(() => { 17 | document.removeEventListener('visibilitychange', listener, false); 18 | backOff(fn, wait * rate, rate, initialWait); 19 | }, wait); 20 | 21 | const listener = () => { 22 | if (!document.hidden) { 23 | document.removeEventListener('visibilitychange', listener, false); 24 | clearTimeout(tid); 25 | backOff(fn, initialWait, rate, initialWait); 26 | } 27 | }; 28 | 29 | if (wait > 1000) { 30 | document.addEventListener('visibilitychange', listener, false); 31 | } 32 | } else { 33 | backOff(fn, wait * rate, rate, initialWait); 34 | } 35 | }); 36 | }; 37 | 38 | // function handleVisibilityChange() { 39 | // if (document.hidden) { 40 | // pauseSimulation(); 41 | // } else { 42 | // startSimulation(); 43 | // } 44 | // } 45 | 46 | // document.addEventListener("visibilitychange", handleVisibilityChange, false); 47 | 48 | export default backOff; 49 | -------------------------------------------------------------------------------- /packages/core/src/debounce.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const debounce = function(fn: () => void): () => void { 4 | let waiting = false; 5 | return items => { 6 | if (!waiting) { 7 | waiting = true; 8 | setTimeout(() => { 9 | fn(); 10 | waiting = false; 11 | }, 0); 12 | } else { 13 | console.log('bouncing'); 14 | } 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/core/src/local-first.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // export type { Client } from './types'; 4 | 5 | // // Delta-based 6 | // export { default as pollingDeltaNetwork } from './delta/polling-network'; 7 | // export { default as webSocketNetwork } from './delta/web-socket-network'; 8 | // export { default as deltaPersistence } from './delta/persistence'; 9 | // export { default as createDeltaClient } from './delta/create-client'; 10 | 11 | // // Full-based 12 | // export { default as pollingNetwork } from './full/polling-network'; 13 | // export { default as googleDriveNetwork } from './full/google-drive-network'; 14 | // export { default as fullPersistence } from './full/full-persistence'; 15 | // export { default as createFullClient } from './full/create-client'; 16 | -------------------------------------------------------------------------------- /packages/core/src/persistent-clock.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as hlc from '../../hybrid-logical-clock/src'; 3 | import type { HLC } from '../../hybrid-logical-clock/src'; 4 | 5 | export const inMemoryClockPersist = () => { 6 | let saved = null; 7 | return { 8 | teardown() {}, 9 | get(init: () => HLC): HLC { 10 | if (!saved) { 11 | saved = init(); 12 | } 13 | return saved; 14 | }, 15 | set(clock: HLC) { 16 | saved = clock; 17 | }, 18 | }; 19 | }; 20 | 21 | export const localStorageClockPersist = (key: string) => ({ 22 | get(init: () => HLC): HLC { 23 | const raw = localStorage.getItem(key); 24 | if (raw == null) { 25 | const res = init(); 26 | localStorage.setItem(key, hlc.pack(res)); 27 | return res; 28 | } 29 | return hlc.unpack(raw); 30 | }, 31 | teardown() { 32 | localStorage.removeItem(key); 33 | }, 34 | set(clock: HLC) { 35 | localStorage.setItem(key, hlc.pack(clock)); 36 | }, 37 | }); 38 | 39 | const genId = () => 40 | Math.random() 41 | .toString(36) 42 | .slice(2); 43 | 44 | type ClockPersist = { 45 | teardown: () => void, 46 | get: (() => HLC) => HLC, 47 | set: HLC => void, 48 | }; 49 | 50 | export class PersistentClock { 51 | persist: ClockPersist; 52 | now: HLC; 53 | constructor(persist: ClockPersist) { 54 | this.persist = persist; 55 | this.now = persist.get(() => hlc.init(genId(), Date.now())); 56 | // $FlowFixMe 57 | this.get = this.get.bind(this); 58 | // $FlowFixMe 59 | this.set = this.set.bind(this); 60 | // $FlowFixMe 61 | this.recv = this.recv.bind(this); 62 | } 63 | 64 | teardown() { 65 | this.persist.teardown(); 66 | } 67 | 68 | get() { 69 | this.now = hlc.inc(this.now, Date.now()); 70 | this.persist.set(this.now); 71 | return hlc.pack(this.now); 72 | } 73 | 74 | set(newClock: HLC) { 75 | this.now = newClock; 76 | this.persist.set(this.now); 77 | } 78 | 79 | recv(newClock: HLC) { 80 | this.now = hlc.recv(this.now, newClock, Date.now()); 81 | this.persist.set(this.now); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/core/src/poller.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // This is a function that will poll periodically, but will 4 | // pause while the window is out of sight. 5 | const poller = (time: number, fn: () => Promise) => { 6 | let tid = null; 7 | const poll = () => { 8 | // console.log('poll'); 9 | clearTimeout(tid); 10 | fn() 11 | .catch(() => {}) 12 | .then(() => { 13 | tid = setTimeout(poll, time); 14 | }); 15 | }; 16 | // $FlowFixMe 17 | if (globalThis.document) { 18 | document.addEventListener( 19 | 'visibilitychange', 20 | () => { 21 | if (document.hidden) { 22 | clearTimeout(tid); 23 | } else { 24 | poll(); 25 | } 26 | }, 27 | false, 28 | ); 29 | window.addEventListener( 30 | 'focus', 31 | () => { 32 | poll(); 33 | }, 34 | false, 35 | ); 36 | } 37 | return poll; 38 | }; 39 | 40 | export default poller; 41 | -------------------------------------------------------------------------------- /packages/core/src/undo-manager.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // TODO this should probably be a little more intellident, so that we could potentially persist the undo history. 4 | export const create = () => { 5 | const history: Array mixed>> = []; 6 | let pending = []; 7 | let timer = null; 8 | return { 9 | add(fn: () => mixed) { 10 | // console.log('add undo'); 11 | pending.push(fn); 12 | if (!timer) { 13 | timer = setTimeout(() => { 14 | timer = null; 15 | if (pending.length) { 16 | // console.log('new history', pending.length); 17 | history.push(pending); 18 | } 19 | pending = []; 20 | }, 0); 21 | } 22 | }, 23 | undo() { 24 | if (pending.length) { 25 | // console.log('undo pending', pending.length); 26 | pending.forEach(fn => fn()); 27 | pending = []; 28 | } 29 | if (history.length) { 30 | const last = history.pop(); 31 | // console.log('undo', last.length); 32 | last.forEach(fn => fn()); 33 | } 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/hybrid-logical-clock/.babelrc: -------------------------------------------------------------------------------- 1 | {"presets": ["@babel/preset-flow", "@babel/preset-env"]} 2 | -------------------------------------------------------------------------------- /packages/hybrid-logical-clock/Makefile: -------------------------------------------------------------------------------- 1 | 2 | FLOW=$(patsubst %.js,lib/%.js.flow,$(wildcard *.js)) 3 | 4 | lib/%.js.flow: %.js 5 | cp $< $@ 6 | 7 | build: $(FLOW) 8 | yarn babel *.js -d lib 9 | -------------------------------------------------------------------------------- /packages/hybrid-logical-clock/Readme.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaredly/local-first/ad301c62bd0a4fe5b423f774760e35171cd089d5/packages/hybrid-logical-clock/Readme.md -------------------------------------------------------------------------------- /packages/hybrid-logical-clock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/hybrid-logical-clock", 3 | "version": "1.0.0", 4 | "devDependencies": { 5 | "@babel/cli": "^7.8.3", 6 | "@babel/core": "^7.8.3", 7 | "@babel/preset-env": "^7.8.4", 8 | "@babel/preset-flow": "^7.8.3", 9 | "@babel/register": "^7.8.3", 10 | "flow-bin": "^0.116.1", 11 | "jest": "^24.9.0", 12 | "prettier": "^1.19.1" 13 | }, 14 | "scripts": { 15 | "test": "node -r @babel/register tests/test-full-fuzz.js", 16 | "prepublish": "babel index.js -d lib && cp index.js lib/index.js.flow" 17 | }, 18 | "main": "src/index.js" 19 | } 20 | -------------------------------------------------------------------------------- /packages/idb/.babelrc: -------------------------------------------------------------------------------- 1 | {"presets": ["@babel/preset-flow", "@babel/preset-env"]} 2 | -------------------------------------------------------------------------------- /packages/idb/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../packages/core 5 | ../../packages/hybrid-logical-clock 6 | ../../packages/nested-object-crdt 7 | 8 | [libs] 9 | 10 | [lints] 11 | 12 | [options] 13 | 14 | [strict] 15 | -------------------------------------------------------------------------------- /packages/idb/Makefile: -------------------------------------------------------------------------------- 1 | 2 | FLOW=$(patsubst src/%.js,lib/%.js.flow,$(wildcard src/**/*.js src/*.js)) 3 | 4 | lib/%.js.flow: src/%.js 5 | cp $< $@ 6 | 7 | dirs: 8 | mkdir -p lib lib/blob lib/delta lib/multi 9 | 10 | build: dirs $(FLOW) 11 | yarn babel src/*.js -d lib 12 | yarn babel src/blob/*.js -d lib/blob 13 | yarn babel src/delta/*.js -d lib/delta 14 | yarn babel src/multi/*.js -d lib/multi 15 | 16 | .PHONY: build -------------------------------------------------------------------------------- /packages/idb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/core-idb", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "@birchill/json-equalish": "^1.1.0", 6 | "broadcast-channel": "^3.0.3", 7 | "fast-deep-equal": "^3.1.1", 8 | "idb": "^5.0.1" 9 | }, 10 | "browserslist": [ 11 | "last 1 Chrome versions" 12 | ], 13 | "devDependencies": { 14 | "@babel/cli": "^7.8.3", 15 | "@babel/core": "^7.0.0-0", 16 | "@babel/preset-env": "^7.8.7", 17 | "@babel/preset-flow": "^7.12.1", 18 | "flow-bin": "^0.116.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/idb/src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type Store = { 4 | get(string): Promise, 5 | getAll(): Promise>, 6 | put(T, ?string): Promise, 7 | count(): Promise, 8 | openCursor: IDBKeyRange => Promise, 9 | index(): Index, 10 | }; 11 | 12 | export type Index = { 13 | openCursor: IDBKeyRange => Promise, 14 | }; 15 | 16 | export type Cursor = { 17 | delete: () => void, 18 | continue: () => ?Promise, 19 | }; 20 | 21 | export type Transaction = { 22 | objectStore(string): Store, 23 | store: Store, 24 | done: Promise, 25 | }; 26 | 27 | export type DB = { 28 | get(string, string): Promise, 29 | getAll(string): Promise>, 30 | count(string): Promise, 31 | transaction(string | Array, 'readonly' | 'readwrite'): Transaction, 32 | }; 33 | -------------------------------------------------------------------------------- /packages/monorepo-pack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@babel/core": "^7.8.7", 4 | "@babel/generator": "^7.8.8", 5 | "@babel/parser": "^7.8.8", 6 | "@babel/preset-env": "^7.8.7", 7 | "@babel/preset-flow": "^7.8.3", 8 | "@babel/traverse": "^7.8.6", 9 | "@babel/types": "^7.8.7", 10 | "recast": "^0.18.7" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-flow", "@babel/preset-env"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"], 4 | "ignore": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../packages/hybrid-logical-clock 5 | ../../packages/rich-text-crdt 6 | 7 | [libs] 8 | flow-typed 9 | 10 | [lints] 11 | sketchy-null=error 12 | sketchy-null-bool=off 13 | 14 | [options] 15 | 16 | [strict] 17 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | coverage 3 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/Readme.md: -------------------------------------------------------------------------------- 1 | # Nested Object CRDT 2 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/old/tests/test-sorted-array.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ presets: ['@babel/preset-flow'] }); 2 | const sa = require('../sorted-array'); 3 | const a = {}; 4 | sa.push(a, 'a'); 5 | console.log(sa.sorted(a), a); 6 | sa.push(a, 'b'); 7 | console.log(sa.sorted(a), a); 8 | sa.push(a, 'c'); 9 | console.log(sa.sorted(a), a); 10 | sa.unshift(a, 'd'); 11 | console.log(sa.sorted(a), a); 12 | sa.insert(a, 'e', 'b', 'c'); 13 | console.log(sa.sorted(a), a); 14 | sa.insert(a, 'f', 'e', 'c'); 15 | console.log(sa.sorted(a), a); 16 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/old/tests/utils/permute.js: -------------------------------------------------------------------------------- 1 | // we're looking for 2 | // commutativity 3 | // associativity 4 | // idempotency 5 | 6 | // maybe generate all the permutations, and then dedup on prefix? 7 | 8 | const check = (initial, ops, apply, eq) => { 9 | const cache = {}; 10 | const seen = {}; 11 | // maybe dynamic programming is all we need. 12 | const all = permute(ops.map((v, i) => [v, i])); 13 | for (let order of all) { 14 | let current = initial; 15 | let is = []; 16 | for (let [op, i] of order) { 17 | is.push(i); 18 | const ukey = is.join(':'); 19 | if (seen[ukey]) { 20 | current = seen[ukey]; 21 | continue; 22 | } 23 | const key = is 24 | .slice() 25 | .sort() 26 | .join(':'); 27 | current = apply(current, op); 28 | seen[ukey] = current; 29 | if (!cache[key]) { 30 | cache[key] = [{ is: [is.slice()], current }]; 31 | } else { 32 | let found = false; 33 | for (let entry of cache[key]) { 34 | if (eq(entry.current, current)) { 35 | entry.is.push(is.slice()); 36 | found = true; 37 | break; 38 | } 39 | } 40 | if (!found) { 41 | cache[key].push({ is: [is.slice()], current }); 42 | } 43 | } 44 | } 45 | } 46 | const failures = []; 47 | for (let key in cache) { 48 | if (cache[key].length > 1) { 49 | failures.push({ key, conflicts: cache[key] }); 50 | } 51 | } 52 | return failures; 53 | }; 54 | 55 | function permute(rest, prefix = []) { 56 | if (rest.length === 0) { 57 | return [prefix]; 58 | } 59 | return [].concat( 60 | ...rest.map((x, index) => { 61 | const oldRest = rest; 62 | const oldPrefix = prefix; 63 | const newRest = rest.slice(0, index).concat(rest.slice(index + 1)); 64 | const newPrefix = prefix.concat([x]); 65 | 66 | const result = permute(newRest, newPrefix); 67 | return result; 68 | }), 69 | ); 70 | } 71 | 72 | module.exports = { check, permute }; 73 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/nested-object-crdt", 3 | "main": "src/index.js", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "test_old": "node -r @babel/register tests/test-full-fuzz.js", 7 | "test": "jest", 8 | "prepublish": "make" 9 | }, 10 | "devDependencies": { 11 | "@babel/cli": "^7.8.3", 12 | "@babel/core": "^7.8.3", 13 | "@babel/plugin-proposal-class-properties": "^7.8.3", 14 | "@babel/preset-env": "^7.8.3", 15 | "@babel/preset-flow": "^7.8.3", 16 | "@babel/register": "^7.8.3", 17 | "flow-bin": "^0.116.1", 18 | "jest": "^24.9.0", 19 | "prettier": "^1.19.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/apply.test.js: -------------------------------------------------------------------------------- 1 | // @-flow 2 | 3 | import * as crdt from './new'; 4 | 5 | const schema = { 6 | type: 'object', 7 | attributes: { children: 'id-array' }, 8 | }; 9 | 10 | const noop = () => { 11 | throw new Error('no other'); 12 | }; 13 | 14 | describe('Insert', () => { 15 | it('should allow insert/remove/insert', () => { 16 | let data = crdt.createWithSchema( 17 | { children: ['a', 'b', 'c'] }, 18 | 'a-stamp', 19 | () => 'a-stamp', 20 | schema, 21 | noop, 22 | ); 23 | const delta = crdt.deltas.insert( 24 | data, 25 | ['children'], 26 | 1, 27 | 'd', 28 | crdt.create('d', 'd-stamp'), 29 | 'd-stamp', 30 | ); 31 | 32 | data = crdt.applyDelta(data, delta, noop); 33 | expect(data.value.children).toEqual(['a', 'd', 'b', 'c']); 34 | data = crdt.applyDelta(data, crdt.deltas.removeAt(data, ['children', 'b'], 'e-stamp')); 35 | expect(data.value.children).toEqual(['a', 'd', 'c']); 36 | const delta2 = crdt.deltas.insert( 37 | data, 38 | ['children'], 39 | 1, 40 | 'b', 41 | crdt.create('b', 'f-stamp'), 42 | 'f-stamp', 43 | ); 44 | data = crdt.applyDelta(data, delta2, noop); 45 | expect(data.value.children).toEqual(['a', 'b', 'd', 'c']); 46 | }); 47 | 48 | it('re-insert in a new place', () => { 49 | let data = crdt.createWithSchema( 50 | { children: ['a', 'b', 'c', 'd'] }, 51 | '1-stamp', 52 | () => '1-stamp', 53 | schema, 54 | noop, 55 | ); 56 | const delta1 = crdt.deltas.insert( 57 | data, 58 | ['children'], 59 | 1, 60 | 'e', 61 | crdt.create('e', '2-stamp'), 62 | '2-stamp', 63 | ); 64 | const delta2 = crdt.deltas.insert( 65 | data, 66 | ['children'], 67 | 3, 68 | 'e', 69 | crdt.create('e', '3-stamp'), 70 | '3-stamp', 71 | ); 72 | data = crdt.applyDelta(data, delta1, noop); 73 | data = crdt.applyDelta(data, delta2, noop); 74 | expect(data.value.children).toEqual(['a', 'b', 'c', 'e', 'd']); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/array-utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type Sort = Array; 4 | 5 | const epsilon = Math.pow(2, -10); 6 | 7 | export const sortForInsertion = (ids: Array, sortForId: string => Sort, idx: number) => { 8 | const pre = idx === 0 ? null : sortForId(ids[idx - 1]); 9 | const post = idx >= ids.length ? null : sortForId(ids[idx]); 10 | return between(pre, post); 11 | }; 12 | 13 | export const insertionIndex = ( 14 | ids: Array, 15 | sortForId: string => Sort, 16 | newSort: Sort, 17 | newId: string, 18 | ) => { 19 | for (let i = 0; i < ids.length; i++) { 20 | const cmp = compare(sortForId(ids[i]), newSort); 21 | if (cmp === 0 && ids[i] > newId) { 22 | return i; 23 | } 24 | if (cmp > 0) { 25 | return i; 26 | } 27 | } 28 | return ids.length; 29 | }; 30 | 31 | export const compare = (one: Array, two: Array) => { 32 | let i = 0; 33 | for (; i < one.length && i < two.length; i++) { 34 | if (Math.abs(one[i] - two[i]) > Number.EPSILON) { 35 | return one[i] - two[i]; 36 | } 37 | } 38 | if (one.length !== two.length) { 39 | return one.length - two.length; 40 | } 41 | return 0; 42 | }; 43 | 44 | export const between = (one: ?Array, two: ?Array): Array => { 45 | if (!one || !two) { 46 | if (one) return [one[0] + 10]; 47 | if (two) return [two[0] - 10]; 48 | return [0]; 49 | } 50 | let i = 0; 51 | const parts = []; 52 | // console.log('between', one, two); 53 | for (; i < one.length && i < two.length; i++) { 54 | if (two[i] - one[i] > epsilon * 2) { 55 | // does this mean that this is the smallest possible difference between two things? 56 | // I don't know actually. Probably possible to construct scenarios that... hmm.. maybe not 57 | // though. 58 | parts.push(one[i] + (two[i] - one[i]) / 2); 59 | return parts; 60 | } 61 | parts.push(one[i]); 62 | } 63 | if (one.length < two.length) { 64 | parts.push(two[i] - 10); 65 | } else if (two.length < one.length) { 66 | parts.push(one[i] + 10); 67 | } else { 68 | parts.push(0); 69 | } 70 | return parts; 71 | }; 72 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/array-utils.test.js: -------------------------------------------------------------------------------- 1 | // @-flow 2 | 3 | import { 4 | sortForInsertion, 5 | // insertionIndex, 6 | compare, 7 | between, 8 | } from './array-utils'; 9 | 10 | describe('between', () => { 11 | it('should compare', () => { 12 | expect(compare([0], [0, 0])).toBeLessThan(0); 13 | }); 14 | it('should stand up to lots of inserts next to each other', () => { 15 | const items = []; 16 | const left = between(null, null); 17 | items.push(left); 18 | let right = between(left, null); 19 | items.push(right); 20 | for (let i = 0; i < 1000; i++) { 21 | const newRight = between(left, right); 22 | expect(compare(left, newRight)).toBeLessThan(0); 23 | expect(compare(newRight, right)).toBeLessThan(0); 24 | right = newRight; 25 | items.splice(1, 0, right); 26 | } 27 | }); 28 | it('should stand up to a bunch of random inserts', () => { 29 | const items = []; 30 | let left = null; 31 | for (let i = 0; i < 100; i++) { 32 | const next = between(left, null); 33 | items.push(next); 34 | left = next; 35 | } 36 | for (let i = 0; i < 10000; i++) { 37 | const idx = parseInt(Math.random() * (items.length + 1)); 38 | const next = between(items[idx - 1], items[idx]); 39 | items.splice(idx, 0, next); 40 | } 41 | for (let i = 0; i < items.length - 1; i++) { 42 | expect(compare(items[i], items[i + 1])).toBeLessThan(0); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/debug.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { CRDT } from './types'; 3 | import deepEqual from 'fast-deep-equal'; 4 | import * as sortedArray from './array-utils'; 5 | 6 | export const checkConsistency = function( 7 | crdt: CRDT, 8 | ): ?Array { 9 | if (crdt.meta.type === 'plain') { 10 | return null; 11 | } 12 | if (crdt.meta.type === 't') { 13 | if (crdt.value != null) { 14 | throw new Error('expected tombstone value to be null'); 15 | } 16 | return; 17 | } 18 | if (crdt.meta.type === 'other') { 19 | return; 20 | } 21 | if (crdt.meta.type === 'map') { 22 | if ( 23 | crdt.value == null || 24 | Array.isArray(crdt.value) || 25 | typeof crdt.value !== 'object' 26 | ) { 27 | throw new Error(`Meta is map, but value doesn't match`); 28 | } 29 | for (let id in crdt.meta.map) { 30 | checkConsistency({ 31 | value: crdt.value[id], 32 | meta: crdt.meta.map[id], 33 | }); 34 | } 35 | return; 36 | } 37 | if (crdt.meta.type === 'array') { 38 | if (crdt.value == null || !Array.isArray(crdt.value)) { 39 | throw new Error(`meta is 'array' but value doesn't match`); 40 | } 41 | const { value, meta } = crdt; 42 | const ids = Object.keys(meta.items) 43 | .filter(key => meta.items[key].meta.type !== 't') 44 | .sort((a, b) => 45 | sortedArray.compare( 46 | meta.items[a].sort.idx, 47 | meta.items[b].sort.idx, 48 | ), 49 | ); 50 | if (!deepEqual(ids, meta.idsInOrder)) { 51 | throw new Error( 52 | `idsInOrder mismatch! ${ids.join( 53 | ',', 54 | )} vs cached ${meta.idsInOrder.join(',')}`, 55 | ); 56 | } 57 | if (value.length !== ids.length) { 58 | throw new Error( 59 | `Value has a different length than non-tombstone IDs`, 60 | ); 61 | } 62 | meta.idsInOrder.forEach((id, i) => { 63 | checkConsistency({ 64 | value: value[i], 65 | meta: meta.items[id].meta, 66 | }); 67 | }); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/in-place-with-array.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import * as sortedArray from './array-utils'; 4 | import type { 5 | CRDT, 6 | Meta, 7 | Sort, 8 | KeyPath, 9 | Delta, 10 | HostDelta, 11 | ArrayMeta, 12 | PlainMeta, 13 | MapMeta, 14 | OtherMerge, 15 | } from './types'; 16 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/new.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { CRDT } from './types'; 4 | export * from './create'; 5 | export * from './types'; 6 | export * from './utils'; 7 | export * from './apply'; 8 | export * from './deltas'; 9 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type { Sort } from './array-utils'; 4 | import type { Sort } from './array-utils'; 5 | 6 | export type KeyPath = Array<{ stamp: string, key: string }>; 7 | 8 | export type ArrayMeta = {| 9 | type: 'array', 10 | items: { 11 | [key: string]: { 12 | sort: { stamp: string, idx: Sort }, 13 | meta: Meta, 14 | }, 15 | }, 16 | // This is just a cache 17 | idsInOrder: Array, 18 | hlcStamp: string, 19 | |}; 20 | 21 | export type MapMeta = {| 22 | type: 'map', 23 | map: { [key: string]: Meta }, 24 | hlcStamp: string, 25 | |}; 26 | 27 | export type OtherMeta = {| 28 | type: 'other', 29 | meta: Other, 30 | hlcStamp: string, 31 | |}; 32 | 33 | export type PlainMeta = {| 34 | type: 'plain', 35 | hlcStamp: string, 36 | |}; 37 | 38 | export type TombstoneMeta = {| 39 | type: 't', 40 | hlcStamp: string, 41 | |}; 42 | 43 | export type HostDelta = 44 | | {| 45 | type: 'set', 46 | path: KeyPath, 47 | value: CRDT, 48 | |} 49 | | {| 50 | type: 'insert', 51 | path: KeyPath, 52 | // The last ID is the ID to add here folks 53 | sort: { idx: Sort, stamp: string }, 54 | value: CRDT, 55 | |} 56 | | {| 57 | type: 'reorder', 58 | path: KeyPath, 59 | sort: { idx: Sort, stamp: string }, 60 | |}; 61 | 62 | export type Delta = 63 | | HostDelta 64 | | { 65 | type: 'other', 66 | path: KeyPath, 67 | delta: OtherDelta, 68 | stamp: string, 69 | }; 70 | 71 | export type Meta = 72 | | MapMeta 73 | | PlainMeta 74 | | TombstoneMeta 75 | | OtherMeta 76 | | ArrayMeta; 77 | 78 | export type CRDT = {| 79 | value: T, 80 | meta: Meta, 81 | |}; 82 | 83 | export type OtherMerge = ( 84 | v1: any, 85 | m1: Other, 86 | v2: any, 87 | m2: Other, 88 | ) => { value: any, meta: Other }; 89 | -------------------------------------------------------------------------------- /packages/nested-object-crdt/src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Meta, CRDT } from './types'; 3 | 4 | const latestMetaStamp = function( 5 | meta: Meta, 6 | otherStamp: Other => ?string, 7 | ): ?string { 8 | if (meta.type === 'map') { 9 | let max = meta.hlcStamp; 10 | Object.keys(meta.map).forEach(id => { 11 | const stamp = latestMetaStamp(meta.map[id], otherStamp); 12 | if (stamp != null && (!max || stamp > max)) { 13 | max = stamp; 14 | } 15 | }); 16 | return max; 17 | } else if (meta.type === 'plain' || meta.type === 't') { 18 | return meta.hlcStamp; 19 | } else if (meta.type === 'array') { 20 | let max = meta.hlcStamp; 21 | Object.keys(meta.items).forEach(id => { 22 | const stamp = latestMetaStamp(meta.items[id].meta, otherStamp); 23 | if (stamp != null && (!max || stamp > max)) { 24 | max = stamp; 25 | } 26 | }); 27 | return max; 28 | } else { 29 | const max = meta.hlcStamp; 30 | const inner = otherStamp(meta.meta); 31 | return inner != null && inner > max ? inner : max; 32 | } 33 | }; 34 | 35 | export const latestStamp = function( 36 | data: CRDT, 37 | otherStamp: Other => ?string, 38 | ): string { 39 | const latest = latestMetaStamp(data.meta, otherStamp); 40 | return latest != null ? latest : ''; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-flow", "@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../hybrid-logical-clock 5 | 6 | [libs] 7 | flow-typed 8 | 9 | [lints] 10 | sketchy-null=error 11 | sketchy-null-bool=off 12 | 13 | [options] 14 | 15 | [strict] 16 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/Readme.md: -------------------------------------------------------------------------------- 1 | # Ok folks here we are 2 | 3 | ## Things this is 4 | 5 | Hello y'all wow this is it. 6 | 7 | Hello this is it y'all 8 | 9 | https://marijnhaverbeke.nl/blog/collaborative-editing.html 10 | 11 | 12 | so, things we can do, with block level stuffs 13 | split a block in two 14 | join two adjacent blocks. 15 | 16 | What happens if another block was inserted in between? 17 | What does that mean? 18 | I think it means that the second block is joined to the middle block? 19 | Like, we've got "block start" tags, and we just delete that start. 20 | 21 | 22 | Can we get away with "block start" tags being the main deals, and then 23 | periodically have "block end" tags? 24 | Do we even need "bock end" tags? 25 | Ah yes, because we can have nesting. 26 | Otherwise, we could just say "a block ends when another begins", which is essentially what Quill is doing. 27 | 28 | 29 | Ok, but so like how do we merge: 30 | 31 |

hello all y'all

(split before y'all) 32 | -> 33 |

hello all

34 |

y'all

35 | 36 | and 37 |

hello all y'all

(split before all) 38 | -> 39 |

hello

40 |

all y'all

41 | 42 | I assume the answer here is 43 |

hello

44 |

all

45 |

y'all

46 | 47 | And we can also merge unsplitting either of them in a reasonable way. 48 | 49 | Now, um, what about splitting & joining list items? 50 | 51 | Also, how do I know when a thing starts and when it ends? 52 | What does "splitting" a node look like? 53 | With the current CRDT, it ended up making much more sense 54 | to have 'override' tags, instead of only deleting a start or end tag. 55 | Can I do the same for block-level stuff? 56 | What would that mean? 57 | Or do we have the "default", which ends up being put in paragraphs, 58 | 59 | 60 | 61 | okkkk what if we have a "split" node? 62 | That just indicates "the thing before and after are siblings"? 63 | And then we have "child" and "unchild" nodes? 64 | 65 | Would that deduplicate correctly? 66 | 67 | hmm idk??? 68 | 69 | 70 | 71 | But a split node is nothing other than a '\n'. Right? 72 | except it would be nice to also have a way to indicate a 'soft-newline'. 73 | 74 |
75 | Hello 76 | 77 | Folks 78 | 79 | Here
80 | 81 | Yeah I think I can just treat it as a normal thing? 82 | oh wait, what about the "join"? 83 | yeah I don't have a way to do that erghhhh 84 | 85 | 86 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/check.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { Node, CRDT } from './types'; 4 | import { contentChars, toKey } from './utils'; 5 | import { walk, fmtIdx } from './loc'; 6 | import deepEqual from 'fast-deep-equal'; 7 | 8 | const checkSize = (state, id) => { 9 | const node = state.map[id]; 10 | let size = node.deleted ? 0 : contentChars(node.content); 11 | node.children.forEach(child => { 12 | checkSize(state, child); 13 | size += state.map[child].size; 14 | }); 15 | if (size !== node.size) { 16 | console.log(size, node.size, node); 17 | throw new Error(`Wrong cached size ${node.size} - should be ${size}; for ${id}`); 18 | } 19 | }; 20 | 21 | export const checkConsistency = (state: CRDT) => { 22 | state.roots.forEach(id => checkSize(state, id)); 23 | checkFormats(state); 24 | }; 25 | 26 | export const checkFormats = (state: CRDT) => { 27 | const format = {}; 28 | walk(state, node => { 29 | if (node.content.type === 'open') { 30 | const content = node.content; 31 | if (!format[content.key]) { 32 | format[content.key] = [toKey(node.id)]; 33 | } else { 34 | const idx = fmtIdx( 35 | format[content.key].map(id => state.map[id].content), 36 | content, 37 | ); 38 | // insert into sorted order. 39 | format[content.key].splice(idx, 0, toKey(node.id)); 40 | } 41 | } else if (node.content.type === 'close') { 42 | const content = node.content; 43 | const f = format[content.key]; 44 | if (!f) { 45 | console.log( 46 | 'Found a "close" marker, but no open marker.', 47 | content.key, 48 | format, 49 | content, 50 | ); 51 | return; 52 | } 53 | const idx = f.findIndex( 54 | item => 55 | state.map[item].content.type !== 'text' && 56 | state.map[item].content.stamp === content.stamp, 57 | ); 58 | if (idx !== -1) { 59 | f.splice(idx, 1); 60 | } 61 | if (!f.length) { 62 | delete format[content.key]; 63 | } 64 | } 65 | if (!deepEqual(format, node.formats)) { 66 | throw new Error( 67 | `Formats mismatch for ${toKey(node.id)}: expected: ${JSON.stringify( 68 | format, 69 | )}; actual: ${JSON.stringify(node.formats)}`, 70 | ); 71 | } 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/debug.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import deepEqual from 'fast-deep-equal'; 4 | import type { CRDT, Delta, Node, Content } from './types'; 5 | 6 | import { apply, insert, del, format, init, walk, fmtIdx, toKey, getFormatValues } from './'; 7 | 8 | type Format = { [key: string]: any }; 9 | 10 | export const walkWithFmt = (state: CRDT, fn: (string, Format) => void) => { 11 | const format = {}; 12 | const fmt: Format = {}; 13 | walk(state, node => { 14 | if (node.content.type === 'text') { 15 | fn(node.content.text, fmt); 16 | } else if (node.content.type === 'open') { 17 | const content = node.content; 18 | if (!format[content.key]) { 19 | format[content.key] = [content]; 20 | } else { 21 | const idx = fmtIdx(format[content.key], content); 22 | // insert into sorted order. 23 | format[content.key].splice(idx, 0, content); 24 | } 25 | fmt[content.key] = format[content.key][0].value; 26 | } else if (node.content.type === 'close') { 27 | const content = node.content; 28 | const f = format[content.key]; 29 | if (!f) { 30 | console.log( 31 | 'Found a "close" marker, but no open marker.', 32 | content.key, 33 | format, 34 | content, 35 | ); 36 | return; 37 | } 38 | const idx = f.findIndex(item => item.stamp === content.stamp); 39 | if (idx !== -1) { 40 | f.splice(idx, 1); 41 | } 42 | if (f.length) { 43 | fmt[content.key] = f[0].value; 44 | } else { 45 | delete fmt[content.key]; 46 | } 47 | } 48 | }); 49 | }; 50 | 51 | export const testSerialize = (state: CRDT, compact: boolean = false) => { 52 | const res = []; 53 | walkWithFmt(state, (text, format) => { 54 | if (compact && res.length && deepEqual(res[res.length - 1].fmt, format)) { 55 | res[res.length - 1].text += text; 56 | } else { 57 | res.push({ text, fmt: { ...format } }); 58 | } 59 | }); 60 | return res; 61 | }; 62 | 63 | export const justContents = (state: CRDT, includeDeleted: boolean = false) => { 64 | const res: Array = []; 65 | walk( 66 | state, 67 | node => { 68 | res.push(node.content); 69 | }, 70 | includeDeleted, 71 | ); 72 | return res; 73 | }; 74 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { Content, CRDT, Node } from './types'; 3 | 4 | export const init = (): CRDT => ({ 5 | largestIDs: {}, 6 | map: {}, 7 | roots: [], 8 | }); 9 | 10 | export * from './merge'; 11 | export * from './deltas'; 12 | export * from './apply'; 13 | export * from './loc'; 14 | export * from './utils'; 15 | export * from './types'; 16 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/loc.test.js: -------------------------------------------------------------------------------- 1 | import { posToLoc, rootSite, adjustSelection } from './loc'; 2 | import { apply, init, insert, del, toString } from './'; 3 | 4 | describe('posToLoc', () => { 5 | it('should worlk for left on empty', () => { 6 | expect(posToLoc(init(), 0, true)).toEqual({ 7 | id: 0, 8 | site: rootSite, 9 | pre: true, 10 | }); 11 | }); 12 | 13 | it('should worlk for left', () => { 14 | let state = init(); 15 | const deltas = insert(state, 'a', 0, 'Hello'); 16 | deltas.forEach(delta => { 17 | state = apply(state, delta); 18 | }); 19 | expect(posToLoc(state, 0, true)).toEqual({ 20 | id: 0, 21 | site: rootSite, 22 | pre: true, 23 | }); 24 | }); 25 | 26 | it('should do another one', () => { 27 | let state = init(); 28 | const deltas = insert(state, 'a', 0, 'Hello'); 29 | deltas.forEach(delta => { 30 | state = apply(state, delta); 31 | }); 32 | expect(posToLoc(state, 1, true)).toEqual({ 33 | id: deltas[0].id[0], 34 | site: deltas[0].id[1], 35 | pre: true, 36 | }); 37 | }); 38 | 39 | it('should properly place selections', () => { 40 | let state = init(); 41 | state = apply(state, insert(state, 'a', 0, 'one two three')); 42 | state = apply(state, insert(state, 'a', 4, 'four ')); 43 | let current = apply(state, del(state, 9, 1)); 44 | current = apply(current, del(current, 8, 1)); 45 | current = apply(current, del(current, 7, 1)); 46 | expect(toString(state)).toEqual('one four two three'); 47 | expect(toString(current)).toEqual('one fouwo three'); 48 | // console.log(JSON.stringify([state, current])); 49 | expect(adjustSelection(state, current, 4, 8)).toEqual({ 50 | start: 4, 51 | end: 7, 52 | }); 53 | 54 | // one two three 55 | // one four two three 56 | // select four 57 | // start deleting from t of two 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@local-first/rich-text-crdt", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "test": "jest" 8 | }, 9 | "devDependencies": { 10 | "@babel/cli": "^7.8.4", 11 | "@babel/core": "^7.8.4", 12 | "@babel/preset-env": "^7.8.4", 13 | "@babel/preset-flow": "^7.8.3", 14 | "@babel/register": "^7.8.3", 15 | "blessed": "^0.1.81", 16 | "chalk": "^3.0.0", 17 | "flow-bin": "^0.118.0", 18 | "jest": "^25.1.0" 19 | }, 20 | "dependencies": { 21 | "@birchill/json-equalish": "^1.1.0", 22 | "fast-deep-equal": "^3.1.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/span.test.js: -------------------------------------------------------------------------------- 1 | // @-flow 2 | 3 | import { selectionToSpans } from './span'; 4 | import { insert } from './deltas'; 5 | import { apply } from './apply'; 6 | import { init } from './'; 7 | import type { InsertDelta } from './types'; 8 | 9 | describe('selectionToSpans', () => { 10 | it('should work', () => { 11 | let state = init(); 12 | const d1 = insert(state, 'a', 0, 'abde'); 13 | const d1id = ((d1[0]: any): InsertDelta).id[0]; 14 | state = apply(state, d1); 15 | const d2 = insert(state, 'a', 2, 'c'); 16 | const d2id = ((d2[0]: any): InsertDelta).id[0]; 17 | state = apply(state, d2); 18 | expect(selectionToSpans(state, 1, 4)).toEqual([ 19 | { site: 'a', id: d1id + 1, length: 1 }, 20 | { site: 'a', id: d2id, length: 1 }, 21 | { site: 'a', id: d1id + 2, length: 1 }, 22 | ]); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/rich-text-crdt/text-binding.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * Functions to facilitate binding to an or