├── .cursor └── settings.json ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── db-api ├── .gitignore ├── README.md ├── requirements.txt └── src │ ├── __init__.py │ ├── app.py │ └── database.py ├── frontend ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── README.md ├── components │ ├── Dashboard.tsx │ ├── DateView.tsx │ ├── SearchView.tsx │ └── timeline │ │ ├── Timeline.js │ │ ├── Timeline.tsx │ │ ├── TimelineBlip.tsx │ │ ├── TimelineEvent.tsx │ │ ├── index.ts │ │ └── styles.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.tsx │ ├── api │ │ └── hello.ts │ └── index.tsx ├── postcss.config.js ├── public │ ├── favicon.ico │ └── vercel.svg ├── styles │ └── globals.css ├── tailwind.config.js ├── tsconfig.json └── utils │ ├── database.types.ts │ └── utils.ts ├── gpt-for-me └── main.py ├── indexer ├── .gitignore ├── README.md ├── requirements.txt ├── setup.py └── src │ ├── __init__.py │ ├── lib │ ├── __init__.py │ ├── database2.py │ └── timestamp.py │ ├── main.py │ ├── schema │ ├── __init__.py │ └── __pycache__ │ │ ├── __init__.cpython-310.pyc │ │ └── __init__.cpython-39.pyc │ └── sources │ ├── __init__.py │ ├── fb_messenger.py │ ├── google_location.py │ ├── imessage.py │ ├── source.py │ └── word_docs.py └── static ├── docs └── TODO.md └── mvp-recording.mov /.cursor/settings.json: -------------------------------------------------------------------------------- 1 | {"repoId":"a3af8861-d59d-43b3-8212-1ed5ae29021c","uploaded":true} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | personal search engine.iml 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nikhil Thota 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/Makefile -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Nikhil's Memex 3 | 4 | So basically, we all have a BOATLOAD of data strewn across different apps. This is my attempt to unify my data into a common warehouse and do cool stuff with it. 'Nuff said, if you want to hear me wax poetic about this or see my progress and attempt to "build in public" you can check out my newsletter on the topic [here](https://thot.substack.com/s/building-a-memex). 5 | 6 | ## Components 7 | 8 | This project is broken up into a bunch of sub-components (not quite microservices) that handle different parts of the memex, ranging from the data collection to the visualization to the API endpoints, etc. Each one has its own `README.md` with information to help you understand how it works. 9 | 10 | ### Indexer 11 | 12 | ### Database API 13 | 14 | ### Frontend 15 | -------------------------------------------------------------------------------- /db-api/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .flaskenv 3 | venv/ 4 | 5 | __pycache__/ 6 | */__pycache__/ 7 | -------------------------------------------------------------------------------- /db-api/README.md: -------------------------------------------------------------------------------- 1 | TODO 2 | - create the relevant database schemas w/ an endpoint 3 | - authenticate request so that we only accept reads & writes from right clients 4 | - endpoint to read from the database given a SQL query 5 | - endpoint to write to the database given a table name and data 6 | - error handling if the right data formatting isn't present 7 | -------------------------------------------------------------------------------- /db-api/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/db-api/requirements.txt -------------------------------------------------------------------------------- /db-api/src/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | def create_app(test_config=None): 5 | # create and configure the app 6 | app = Flask(__name__, instance_relative_config=True) 7 | if test_config is None: 8 | app.config.from_mapping( 9 | SECRET_KEY="dev", 10 | ) 11 | else: 12 | app.config.from_mapping(test_config) 13 | 14 | return app 15 | -------------------------------------------------------------------------------- /db-api/src/app.py: -------------------------------------------------------------------------------- 1 | 2 | from dotenv import load_dotenv 3 | from flask import Flask 4 | 5 | load_dotenv() 6 | 7 | app = Flask(__name__) 8 | 9 | -------------------------------------------------------------------------------- /db-api/src/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import psycopg2 3 | 4 | # global connection object 5 | conn = None 6 | 7 | 8 | def connect_db(): 9 | db_host = os.getenv('DB_HOST') 10 | db_port = os.getenv('DB_PORT') 11 | db_name = os.getenv("DB_NAME") 12 | db_user = os.getenv("DB_USER") 13 | db_password = os.getenv("DB_PASSWORD") 14 | 15 | connection = psycopg2.connect(dbname=db_name, user=db_user, password=db_password, host=db_host, port=db_port) 16 | print("***OPENING CONNECTION***") 17 | return connection 18 | 19 | 20 | def get_db(): 21 | global conn 22 | if conn is None: 23 | conn = connect_db() 24 | return conn 25 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "extends": [ 4 | "next/core-web-vitals", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" // Add "prettier" last. This will turn off eslint rules conflicting with prettier. This is not what will format our code. 7 | ], 8 | "rules": { 9 | // I suggest you add those two rules: 10 | "@typescript-eslint/no-unused-vars": "error", 11 | "@typescript-eslint/no-explicit-any": "error" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/frontend/README.md -------------------------------------------------------------------------------- /frontend/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { Session } from '@supabase/auth-helpers-react' 3 | import DateView from './DateView' 4 | import SearchView from './SearchView' 5 | 6 | export default function Dashboard({ session }: { session: Session }) { 7 | const [isSearch, setIsSearch] = useState(true) 8 | 9 | useEffect(() => { 10 | // Load initial data (if any) 11 | }, [session]) 12 | 13 | return ( 14 |
15 | 22 |
{isSearch ? : }
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/components/DateView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { parseDate } from '../utils/utils' 3 | import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react' 4 | import { Database } from '../utils/database.types' 5 | import { Timeline, TimelineEvent } from './timeline' 6 | type Record = Database['public']['Tables']['records']['Row'] 7 | type Location = Database['public']['Tables']['locations']['Row'] 8 | 9 | const DateView = () => { 10 | const supabase = useSupabaseClient() 11 | const user = useUser() 12 | const [date, setDate] = useState('') 13 | const [records, setRecords] = useState([]) 14 | const [locations, setLocations] = useState([]) 15 | 16 | async function getDataByDates() { 17 | try { 18 | if (!user) throw new Error('No user') 19 | 20 | const dateObj = new Date(date + ' 00:00:00') 21 | const tzOffst = dateObj.getTimezoneOffset() * 60000 22 | const startDate = new Date(dateObj.getTime() + tzOffst).toISOString() 23 | const endDate = new Date( 24 | dateObj.getTime() + tzOffst + 86400000 25 | ).toISOString() 26 | 27 | const { 28 | data: recordsData, 29 | error: recordsError, 30 | status: recordsStatus, 31 | } = await supabase 32 | .from('records') 33 | .select(`id, source, title, content, time, link`) 34 | .gte('time', startDate) 35 | .lte('time', endDate) 36 | .order('time', { ascending: true }) 37 | 38 | if (recordsError && recordsStatus !== 406) { 39 | throw recordsError 40 | } 41 | 42 | if (recordsData) { 43 | setRecords(recordsData) 44 | } 45 | 46 | const { 47 | data: locationsData, 48 | error: locationsError, 49 | status: locationsStatus, 50 | } = await supabase 51 | .from('locations') 52 | .select(`id, title, start_time, end_time`) 53 | .gte('end_time', startDate) 54 | .lte('start_time', endDate) 55 | .order('start_time', { ascending: true }) 56 | 57 | if (locationsError && locationsStatus !== 406) { 58 | throw locationsError 59 | } 60 | 61 | if (locationsData) { 62 | setLocations(locationsData) 63 | } 64 | } catch (error) { 65 | alert('Error loading user data!') 66 | console.log(error) 67 | } finally { 68 | } 69 | } 70 | 71 | return ( 72 |
73 |
74 | setDate(e.target.value)} 79 | /> 80 | 87 |
88 | {locations.map((location) => ( 89 |
90 |

91 | {location.title}
{parseDate(location.start_time)} to{' '} 92 | {parseDate(location.end_time)} 93 |

94 | 95 | {records 96 | .filter( 97 | (record) => 98 | record.time > location.start_time && 99 | record.time < location.end_time 100 | ) 101 | .map((record) => ( 102 | {record.source}} 107 | > 108 | {record.content} 109 | 110 | ))} 111 | 112 |
113 | ))} 114 |
115 | ) 116 | } 117 | 118 | export default DateView 119 | -------------------------------------------------------------------------------- /frontend/components/SearchView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react' 3 | import { Database } from '../utils/database.types' 4 | import { parseDate } from '../utils/utils' 5 | type Record = Database['public']['Tables']['records']['Row'] 6 | 7 | export default function SearchView() { 8 | const supabase = useSupabaseClient() 9 | const user = useUser() 10 | const [searchTerm, setSearchTerm] = useState('') 11 | const [records, setRecords] = useState([]) 12 | 13 | async function getRecordsBySearch() { 14 | try { 15 | if (!user) throw new Error('No user') 16 | 17 | const { data, error, status } = await supabase 18 | .from('records') 19 | .select(`id, source, title, content, time, link`) 20 | // TODO: eventually figure out a better way to do a FTS instead 21 | .like('content', '%' + searchTerm + '%') 22 | .order('time', { ascending: false }) 23 | 24 | if (error && status !== 406) { 25 | throw error 26 | } 27 | 28 | if (data) { 29 | setRecords(data) 30 | } 31 | } catch (error) { 32 | alert('Error loading user data!') 33 | console.log(error) 34 | } finally { 35 | } 36 | } 37 | 38 | return ( 39 |
40 |
41 | setSearchTerm(e.target.value)} 47 | /> 48 | 55 |
56 |
    57 | {records.map((record) => ( 58 |
    62 |
  • 63 | {parseDate(record.time)} 64 |
  • 65 |
  • 66 | {record.title} 67 |
    68 | {record.content} 69 |
  • 70 |
    71 | ))} 72 |
73 |
74 | ) 75 | } 76 | 77 | // export default SearchView 78 | -------------------------------------------------------------------------------- /frontend/components/timeline/Timeline.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import s from './styles' 4 | 5 | class Timeline extends Component { 6 | render () { 7 | const { orientation = 'left', children, lineColor, lineStyle, style, ...otherProps } = this.props 8 | const childrenWithProps = React.Children.map(children, child => React.cloneElement(child, { orientation })) 9 | let leftOrRight = (orientation === 'right') ? { ...s['containerBefore--right'] } : { ...s['containerBefore--left'] } 10 | let lineAppearance = { ...leftOrRight, ...lineStyle } 11 | lineAppearance = lineColor ? { ...lineAppearance, background: lineColor } : lineAppearance 12 | return ( 13 |
14 |
15 |
16 | {childrenWithProps} 17 |
18 |
19 |
20 | ) 21 | } 22 | } 23 | 24 | Timeline.propTypes = { 25 | children: PropTypes.node.isRequired, 26 | orientation: PropTypes.string, 27 | style: PropTypes.object, 28 | lineColor: PropTypes.string, 29 | lineStyle: PropTypes.object 30 | } 31 | 32 | Timeline.defaultProps = { 33 | style: {}, 34 | lineStyle: {} 35 | } 36 | 37 | export default Timeline 38 | -------------------------------------------------------------------------------- /frontend/components/timeline/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import s from './styles' 3 | 4 | type TimelineProps = { 5 | children: React.ReactNode 6 | orientation?: string 7 | style?: React.CSSProperties 8 | lineColor?: string 9 | lineStyle?: React.CSSProperties 10 | } 11 | 12 | function Timeline(props: TimelineProps) { 13 | const { 14 | children, 15 | orientation = 'left', 16 | style = {}, 17 | lineColor, 18 | lineStyle = {}, 19 | } = props 20 | const childrenWithProps = React.Children.map( 21 | children, 22 | (child) => { 23 | if (React.isValidElement(child)) { 24 | return React.cloneElement(child, orientation) 25 | } 26 | return child 27 | } 28 | ) 29 | const leftOrRight = 30 | orientation === 'right' 31 | ? { ...s['containerBefore--right'] } 32 | : { ...s['containerBefore--left'] } 33 | let lineAppearance: React.CSSProperties = { ...leftOrRight, ...lineStyle } 34 | lineAppearance = lineColor 35 | ? { ...lineAppearance, background: lineColor } 36 | : lineAppearance 37 | return ( 38 |
39 |
40 |
46 | {childrenWithProps} 47 |
48 |
49 |
50 | ) 51 | } 52 | 53 | export default Timeline 54 | -------------------------------------------------------------------------------- /frontend/components/timeline/TimelineBlip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import s from './styles' 3 | 4 | type TimelineBlipProps = { 5 | title: React.ReactNode 6 | icon?: React.ReactNode 7 | iconColor?: string 8 | iconStyle?: React.CSSProperties 9 | orientation?: string 10 | style?: React.CSSProperties 11 | } 12 | 13 | function TimelineBlip(props: TimelineBlipProps) { 14 | const { 15 | title, 16 | icon, 17 | iconColor, 18 | iconStyle = {}, 19 | orientation, 20 | style = {}, 21 | ...otherProps 22 | } = props 23 | 24 | function mergeNotificationStyle( 25 | iconColor: string | undefined 26 | ): React.CSSProperties { 27 | return iconColor 28 | ? { 29 | ...(s.eventType as React.CSSProperties), 30 | ...{ color: iconColor, borderColor: iconColor }, 31 | } 32 | : (s.eventType as React.CSSProperties) 33 | } 34 | 35 | function getIconStyle(iconStyle: React.CSSProperties): React.CSSProperties { 36 | return { ...(s.materialIcons as React.CSSProperties), ...iconStyle } 37 | } 38 | 39 | const leftOrRightEvent: React.CSSProperties = 40 | orientation === 'right' 41 | ? { ...(s['event--right'] as React.CSSProperties) } 42 | : { ...(s['event--left'] as React.CSSProperties) } 43 | return ( 44 |
51 |
52 | {icon} 53 |
54 |
58 |
{title}
59 |
60 |
61 |
62 | ) 63 | } 64 | 65 | export default TimelineBlip 66 | -------------------------------------------------------------------------------- /frontend/components/timeline/TimelineEvent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import s from './styles' 3 | 4 | type TimelineEventProps = { 5 | title: React.ReactNode 6 | subtitle?: React.ReactNode 7 | createdAt?: React.ReactNode 8 | children?: React.ReactNode 9 | buttons?: React.ReactNode 10 | container?: string 11 | icon?: React.ReactNode 12 | iconColor?: string 13 | orientation?: string 14 | collapsible?: boolean 15 | showContent?: boolean 16 | className?: string 17 | onClick?: (evt: React.MouseEvent) => void 18 | onIconClick?: (evt: React.MouseEvent) => void 19 | } 20 | 21 | function TimelineEvent(props: TimelineEventProps) { 22 | const { 23 | createdAt = undefined, 24 | title, 25 | subtitle, 26 | buttons, 27 | icon, 28 | iconColor, 29 | orientation = 'left', 30 | showContent = false, 31 | className = '', 32 | collapsible, 33 | onClick, 34 | onIconClick, 35 | container, 36 | children, 37 | } = props 38 | const [showContentState, setShowContentState] = useState(showContent) 39 | 40 | function mergeNotificationStyle( 41 | iconColor: string | undefined, 42 | orientation: string | undefined 43 | ): React.CSSProperties { 44 | const iconColorStyle: React.CSSProperties = iconColor 45 | ? { 46 | ...(s.eventType as React.CSSProperties), 47 | ...{ color: iconColor, borderColor: iconColor }, 48 | } 49 | : (s.eventType as React.CSSProperties) 50 | const leftOrRight: React.CSSProperties = 51 | orientation === 'right' 52 | ? { ...s['eventType--right'] } 53 | : { ...s['eventType--left'] } 54 | return { ...iconColorStyle, ...leftOrRight } 55 | } 56 | 57 | function mergeContentStyle() { 58 | return showAsCard() ? s.cardBody : s.message 59 | } 60 | 61 | function timeStyle(): React.CSSProperties { 62 | return showAsCard() ? s.time : { ...s.time, color: '#303e49' } 63 | } 64 | 65 | function showAsCard() { 66 | return container === 'card' 67 | } 68 | 69 | function containerStyle(): React.CSSProperties { 70 | const containerStyle: React.CSSProperties = { 71 | ...(s.eventContainer as React.CSSProperties), 72 | } 73 | return showAsCard() 74 | ? { ...(s.card as React.CSSProperties), ...containerStyle } 75 | : containerStyle 76 | } 77 | 78 | function toggleStyle(): React.CSSProperties { 79 | const messageStyle = container === 'card' ? { ...s.cardTitle } : {} 80 | return collapsible ? { ...s.toggleEnabled, ...messageStyle } : messageStyle 81 | } 82 | 83 | function toggleContent() { 84 | setShowContentState(!showContentState) 85 | } 86 | 87 | function renderChildren() { 88 | return (collapsible && showContent) || !collapsible ? ( 89 |
90 | {children} 91 |
92 |
93 | ) : ( 94 | 98 | … 99 | 100 | ) 101 | } 102 | 103 | const leftOrRightEventStyling: React.CSSProperties = 104 | orientation === 'right' 105 | ? { ...(s['event--right'] as React.CSSProperties) } 106 | : { ...(s['event--left'] as React.CSSProperties) } 107 | const leftOrRightButtonStyling: React.CSSProperties = 108 | orientation === 'left' 109 | ? { ...(s['actionButtons--right'] as React.CSSProperties) } 110 | : { ...(s['actionButtons--left'] as React.CSSProperties) } 111 | return ( 112 |
118 |
119 | 123 | {icon} 124 | 125 |
126 |
127 |
128 |
132 | {createdAt &&
{createdAt}
} 133 |
{title}
134 | {subtitle &&
{subtitle}
} 135 |
136 | {buttons} 137 |
138 |
139 | {children && renderChildren()} 140 |
141 |
142 |
143 | ) 144 | } 145 | 146 | export default TimelineEvent 147 | -------------------------------------------------------------------------------- /frontend/components/timeline/index.ts: -------------------------------------------------------------------------------- 1 | import Timeline from './Timeline' 2 | import TimelineEvent from './TimelineEvent' 3 | import TimelineBlip from './TimelineBlip' 4 | 5 | export { Timeline, TimelineEvent, TimelineBlip } 6 | -------------------------------------------------------------------------------- /frontend/components/timeline/styles.ts: -------------------------------------------------------------------------------- 1 | const style = { 2 | 3 | container: { 4 | position: 'relative', 5 | fontSize: '80%', 6 | fontWeight: 300, 7 | padding: '10px 0', 8 | width: '95%', 9 | margin: '0 auto' 10 | }, 11 | containerBefore: { 12 | content: '', 13 | position: 'absolute', 14 | top: 0, 15 | height: '100%', 16 | width: 2, 17 | background: '#a0b2b8' 18 | }, 19 | 'containerBefore--left': { 20 | left: '16px' 21 | }, 22 | 'containerBefore--right': { 23 | right: '14px' 24 | }, 25 | containerAfter: { 26 | content: '', 27 | display: 'table', 28 | clear: 'both' 29 | }, 30 | event: { 31 | position: 'relative', 32 | margin: '10px 0' 33 | }, 34 | 'event--left': { 35 | paddingLeft: 45, 36 | textAlign: 'left' 37 | }, 38 | 'event--right': { 39 | paddingRight: 45, 40 | textAlign: 'right' 41 | }, 42 | eventAfter: { 43 | clear: 'both', 44 | content: '', 45 | display: 'table' 46 | }, 47 | eventType: { 48 | position: 'absolute', 49 | top: 0, 50 | borderRadius: '50%', 51 | width: 30, 52 | height: 30, 53 | marginLeft: 1, 54 | background: '#e9f0f5', 55 | border: '2px solid #6fba1c', 56 | display: 'flex' 57 | }, 58 | 'eventType--left': { 59 | left: 0 60 | }, 61 | 'eventType--right': { 62 | right: 0 63 | }, 64 | materialIcons: { 65 | display: 'flex', 66 | width: 32, 67 | height: 32, 68 | position: 'relative', 69 | justifyContent: 'center', 70 | cursor: 'pointer', 71 | alignSelf: 'center', 72 | alignItems: 'center' 73 | }, 74 | eventContainer: { 75 | position: 'relative' 76 | }, 77 | eventContainerBefore: { 78 | top: 24, 79 | left: '100%', 80 | borderColor: 'transparent', 81 | borderLeftColor: '#ffffff' 82 | }, 83 | time: { 84 | marginBottom: 3 85 | }, 86 | subtitle: { 87 | marginTop: 2, 88 | fontSize: '85%', 89 | color: '#777' 90 | }, 91 | message: { 92 | width: '98%', 93 | backgroundColor: '#ffffff', 94 | boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', 95 | marginTop: '1em', 96 | marginBottom: '1em', 97 | lineHeight: 1.6, 98 | padding: '0.5em 1em' 99 | }, 100 | messageAfter: { 101 | clear: 'both', 102 | content: '', 103 | display: 'table' 104 | }, 105 | actionButtons: { 106 | marginTop: -20 107 | }, 108 | 'actionButtons--left': { 109 | float: 'left', 110 | textAlign: 'left' 111 | }, 112 | 'actionButtons--right': { 113 | float: 'right', 114 | textAlign: 'right' 115 | }, 116 | card: { 117 | boxShadow: 'rgba(0, 0, 0, 0.117647) 0px 1px 6px, rgba(0, 0, 0, 0.117647) 0px 1px 4px', 118 | backgroundColor: 'rgb(255, 255, 255)' 119 | }, 120 | cardTitle: { 121 | backgroundColor: '#7BB1EA', 122 | padding: 10, 123 | color: '#fff' 124 | }, 125 | cardBody: { 126 | backgroundColor: '#ffffff', 127 | marginBottom: '1em', 128 | lineHeight: 1.6, 129 | padding: 10, 130 | minHeight: 40 131 | }, 132 | blipStyle: { 133 | position: 'absolute', 134 | top: '50%', 135 | marginTop: '9px' 136 | }, 137 | toggleEnabled: { 138 | cursor: 'pointer' 139 | } 140 | } 141 | 142 | export default style 143 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@supabase/auth-helpers-nextjs": "^0.5.2", 13 | "@supabase/auth-helpers-react": "^0.3.1", 14 | "@supabase/auth-ui-react": "^0.2.6", 15 | "@supabase/supabase-js": "^2.1.1", 16 | "@types/node": "18.11.10", 17 | "@types/react": "18.0.25", 18 | "@types/react-dom": "18.0.9", 19 | "eslint": "8.28.0", 20 | "eslint-config-next": "13.0.5", 21 | "next": "13.0.5", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "typescript": "4.9.3" 25 | }, 26 | "devDependencies": { 27 | "@typescript-eslint/eslint-plugin": "^5.45.1", 28 | "autoprefixer": "^10.4.13", 29 | "eslint-config-prettier": "^8.5.0", 30 | "postcss": "^8.4.19", 31 | "prettier": "^2.8.0", 32 | "tailwindcss": "^3.2.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs' 2 | import { SessionContextProvider, Session } from '@supabase/auth-helpers-react' 3 | import { useState } from 'react' 4 | import { AppProps } from 'next/app' 5 | 6 | import 'styles/globals.css' 7 | 8 | function MyApp({ 9 | Component, 10 | pageProps, 11 | }: AppProps<{ 12 | initialSession: Session 13 | }>) { 14 | const [supabase] = useState(() => createBrowserSupabaseClient()) 15 | 16 | return ( 17 | 21 | 22 | 23 | ) 24 | } 25 | export default MyApp 26 | -------------------------------------------------------------------------------- /frontend/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /frontend/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Auth, ThemeSupa } from '@supabase/auth-ui-react' 2 | import { useSession, useSupabaseClient } from '@supabase/auth-helpers-react' 3 | import Dashboard from '../components/Dashboard' 4 | 5 | const Home = () => { 6 | const session = useSession() 7 | const supabase = useSupabaseClient() 8 | 9 | return ( 10 |
11 | {!session ? ( 12 | 13 | ) : ( 14 | 15 | )} 16 |
17 | ) 18 | } 19 | 20 | export default Home 21 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | // apps/site/postcss.config.js 2 | const { join } = require('path'); 3 | 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: { 7 | config: join(__dirname, 'tailwind.config.js'), 8 | }, 9 | autoprefixer: {}, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx}', 5 | './components/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /frontend/utils/database.types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json } 7 | | Json[] 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | profiles: { 13 | Row: { 14 | id: string 15 | updated_at: string | null 16 | username: string | null 17 | full_name: string | null 18 | avatar_url: string | null 19 | website: string | null 20 | } 21 | Insert: { 22 | id: string 23 | updated_at?: string | null 24 | username?: string | null 25 | full_name?: string | null 26 | avatar_url?: string | null 27 | website?: string | null 28 | } 29 | Update: { 30 | id?: string 31 | updated_at?: string | null 32 | username?: string | null 33 | full_name?: string | null 34 | avatar_url?: string | null 35 | website?: string | null 36 | } 37 | } 38 | records: { 39 | Row: { 40 | id: string 41 | source: string 42 | title: string 43 | content: string 44 | time: string 45 | link: string | null 46 | } 47 | } 48 | locations: { 49 | Row: { 50 | id: string 51 | title: string 52 | start_time: string 53 | end_time: string 54 | } 55 | } 56 | } 57 | Views: { 58 | [_ in never]: never 59 | } 60 | Functions: { 61 | [_ in never]: never 62 | } 63 | Enums: { 64 | [_ in never]: never 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/utils/utils.ts: -------------------------------------------------------------------------------- 1 | const parseDate = (date: string): string => { 2 | let dateItem = new Date(Date.parse(date)) 3 | return dateItem.toLocaleDateString() + ' ' + dateItem.toLocaleTimeString() 4 | } 5 | 6 | export { parseDate } 7 | -------------------------------------------------------------------------------- /gpt-for-me/main.py: -------------------------------------------------------------------------------- 1 | import tweepy 2 | import csv 3 | 4 | # Replace these with your own Twitter API keys and access tokens 5 | consumer_key = 'your_consumer_key' 6 | consumer_secret = 'your_consumer_secret' 7 | access_token = 'your_access_token' 8 | access_token_secret = 'your_access_token_secret' 9 | 10 | # Authenticate with the Twitter API 11 | auth = tweepy.OAuthHandler(consumer_key, consumer_secret) 12 | auth.set_access_token(access_token, access_token_secret) 13 | api = tweepy.API(auth) 14 | 15 | 16 | def get_all_likes(api): 17 | likes = [] 18 | for status in tweepy.Cursor(api.favorites).items(): 19 | tweet = { 20 | 'id': status.id_str, 21 | 'created_at': status.created_at, 22 | 'text': status.text, 23 | 'user': status.user.screen_name, 24 | } 25 | likes.append(tweet) 26 | return likes 27 | 28 | 29 | def save_likes_to_csv(likes, filename='likes.csv'): 30 | with open(filename, 'w', newline='', encoding='utf-8') as csvfile: 31 | fieldnames = ['id', 'created_at', 'text', 'user'] 32 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 33 | writer.writeheader() 34 | for like in likes: 35 | writer.writerow(like) 36 | 37 | 38 | if __name__ == "__main__": 39 | likes = get_all_likes(api) 40 | save_likes_to_csv(likes) 41 | -------------------------------------------------------------------------------- /indexer/.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | db/ 3 | .env 4 | 5 | */__pycache__/ 6 | */*/__pycache__/ 7 | -------------------------------------------------------------------------------- /indexer/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/README.md -------------------------------------------------------------------------------- /indexer/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/requirements.txt -------------------------------------------------------------------------------- /indexer/setup.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/setup.py -------------------------------------------------------------------------------- /indexer/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/__init__.py -------------------------------------------------------------------------------- /indexer/src/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/lib/__init__.py -------------------------------------------------------------------------------- /indexer/src/lib/database2.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from supabase import create_client 4 | 5 | 6 | class Database: 7 | def __init__(self): 8 | load_dotenv() 9 | url: str = os.environ.get("SUPABASE_URL") 10 | key: str = os.environ.get("SUPABASE_SECRET_KEY") 11 | self.supabase = create_client(url, key) 12 | 13 | def save_record(self, source, title, content, time, link): 14 | record = {'source': source, 'title': title, 'content': content, 'time': time, 'link': link} 15 | self.supabase.table('records').insert(record).execute() 16 | 17 | def save_records(self, records): 18 | self.supabase.table('records').insert(records).execute() 19 | 20 | def save_location(self, title, start_time, end_time): 21 | record = {'title': title, 'start_time': start_time, 'end_time': end_time} 22 | self.supabase.table('locations').insert(record).execute() 23 | 24 | def save_locations(self, locations): 25 | self.supabase.table('locations').insert(locations).execute() 26 | -------------------------------------------------------------------------------- /indexer/src/lib/timestamp.py: -------------------------------------------------------------------------------- 1 | import re 2 | import calendar 3 | from datetime import datetime 4 | 5 | 6 | def to_timestamp(time_string: str) -> int: 7 | return round(datetime.strptime(time_string, "%Y-%m-%d %H:%M:%S").timestamp()) 8 | 9 | 10 | def to_timestamp_loc(time_string: str) -> datetime: 11 | remove_fraction = re.sub(r"\.\d+", "", time_string) 12 | timestamp_int = calendar.timegm(datetime.strptime(remove_fraction, "%Y-%m-%dT%H:%M:%SZ").utctimetuple()) 13 | return datetime.utcfromtimestamp(timestamp_int) 14 | 15 | 16 | def from_timestamp(timestamp: int) -> datetime: 17 | return datetime.utcfromtimestamp(timestamp) 18 | 19 | 20 | def date_to_range(date: str) -> (int, int): 21 | start = to_timestamp(date + " 00:00:00") 22 | end = to_timestamp(date + " 23:59:59") 23 | return start, end 24 | -------------------------------------------------------------------------------- /indexer/src/main.py: -------------------------------------------------------------------------------- 1 | from lib.database2 import Database 2 | from lib.timestamp import from_timestamp, date_to_range 3 | from sources.source import get_records 4 | 5 | 6 | def main(): 7 | print("Hello! Welcome to your Memex. What would you like to do? These options define the main API for your Memex.") 8 | print("1. Re-index your data") 9 | print("2. Search your data") 10 | print("3. Output a particular day's data") 11 | choice = int(input("Enter your choice: ")) 12 | 13 | db = Database() 14 | 15 | # Re-index 16 | if choice == 1: 17 | # db.reset() 18 | 19 | get_records(db) 20 | 21 | # Search 22 | elif choice == 2: 23 | term = input("Enter a search term: ") 24 | output = db.search(term) 25 | for item in output: 26 | print(item[3], from_timestamp(item[3]), item[2]) 27 | 28 | # Output a particular day's data 29 | elif choice == 3: 30 | day = input("Enter a day (YYYY-MM-DD): ") 31 | [start, end] = date_to_range(day) 32 | print(start, end) 33 | output = db.search_day(day) 34 | for item in output: 35 | print(item[3], from_timestamp(item[3]), item[2]) 36 | 37 | 38 | if __name__ == "__main__": 39 | main() 40 | -------------------------------------------------------------------------------- /indexer/src/schema/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/schema/__init__.py -------------------------------------------------------------------------------- /indexer/src/schema/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/schema/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /indexer/src/schema/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/schema/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /indexer/src/sources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/indexer/src/sources/__init__.py -------------------------------------------------------------------------------- /indexer/src/sources/fb_messenger.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import json 3 | 4 | from indexer.src.lib.database2 import Database 5 | 6 | import re 7 | 8 | from indexer.src.lib.timestamp import from_timestamp 9 | 10 | messenger_path = "./indexer/data/fb-messenger/messages/*/*.json" 11 | # messenger_path = "./indexer/data/fb-messenger/messages/mackenziepatel_8sbrouv7sg/*.json" 12 | messenger_prefix = "msgr" 13 | 14 | 15 | def get_messenger_records(db: Database): 16 | messenger_json_list = glob.glob(messenger_path) 17 | 18 | for file_index, file in enumerate(messenger_json_list, len(messenger_json_list) - 1): 19 | with open(file, "r") as f: 20 | data = json.load(f) 21 | 22 | messages_with_content = (messages for messages in data["messages"] if 'content' in messages) 23 | db_entries = [] 24 | for message_index, message in enumerate(messages_with_content): 25 | # msgr saves special char content in a weird way so we have to decode it 26 | message_content = re.sub(r'[\xc2-\xf4][\x80-\xbf]+', 27 | lambda m: m.group(0).encode('latin1').decode('utf8'), message["content"]) 28 | 29 | if content_filter(message_content): 30 | continue 31 | 32 | title = "Messenger Thread with " + data["title"] 33 | content = message["sender_name"] + ": " + message_content 34 | time = from_timestamp(round(message["timestamp_ms"]/1000)).isoformat() 35 | 36 | db_entries.append({'source': messenger_prefix, 'title': title, 'content': content, 'time': time, 'link': ''}) 37 | db.save_records(db_entries) 38 | 39 | 40 | def content_filter(content): 41 | filters = ['missed a call', 'missed your call', 'Reacted'] 42 | for filter_str in filters: 43 | if filter_str in content: 44 | return True 45 | return False 46 | 47 | # TODO: Create a more scalable system for content filtering on a per-integration basis 48 | -------------------------------------------------------------------------------- /indexer/src/sources/google_location.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import json 3 | 4 | from indexer.src.lib.database2 import Database 5 | 6 | from indexer.src.lib.timestamp import to_timestamp_loc 7 | 8 | location_path = "./indexer/data/google/Location History/Semantic Location History/*/*.json" 9 | # location_path = "./indexer/data/google/Location History/Semantic Location History/2022/2022_NOVEMBER.json" 10 | location_prefix = "maps" 11 | 12 | 13 | def get_location_data(db: Database): 14 | location_json_list = glob.glob(location_path) 15 | 16 | for file in location_json_list: 17 | with open(file, "r") as f: 18 | data = json.load(f) 19 | 20 | db_entries = [] 21 | for timelineObject in data["timelineObjects"]: 22 | if "placeVisit" in timelineObject: 23 | place_visit = timelineObject["placeVisit"] 24 | if "address" in place_visit["location"]: 25 | title = place_visit["location"]["address"] 26 | if "name" in place_visit["location"]: 27 | title = place_visit["location"]["name"] + ": " + title 28 | elif "otherCandidateLocations" in place_visit: 29 | try: 30 | title = place_visit["otherCandidateLocations"][0]["address"] 31 | except KeyError: 32 | title = "Unknown Location" 33 | else: 34 | title = "Unknown Location" 35 | start_time = to_timestamp_loc(place_visit["duration"]["startTimestamp"]).isoformat() 36 | end_time = to_timestamp_loc(place_visit["duration"]["endTimestamp"]).isoformat() 37 | 38 | elif "activitySegment" in timelineObject: 39 | activity_segment = timelineObject["activitySegment"] 40 | try: 41 | title = activity_segment["activityType"] 42 | except KeyError: 43 | title = "Unknown Activity" 44 | if "distance" in activity_segment: 45 | title += " for " + str(round(activity_segment["distance"] / 1600, 1)) + " miles" 46 | start_time = to_timestamp_loc(activity_segment["duration"]["startTimestamp"]).isoformat() 47 | end_time = to_timestamp_loc(activity_segment["duration"]["endTimestamp"]).isoformat() 48 | 49 | db_entries.append({'title': title, 'start_time': start_time, 'end_time': end_time}) 50 | print(title, start_time, end_time) 51 | db.save_locations(db_entries) 52 | -------------------------------------------------------------------------------- /indexer/src/sources/imessage.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | from indexer.src.lib.database2 import Database 4 | from indexer.src.lib.timestamp import to_timestamp, from_timestamp 5 | 6 | imessage_path = "./indexer/data/imessage/chat.db" 7 | # imessage_path = "~/Library/Messages/chat.db" 8 | imessage_prefix = "imsg" 9 | 10 | 11 | def get_imessage_records(db: Database): 12 | conn = sqlite3.connect(imessage_path) 13 | cur = conn.cursor() 14 | 15 | # Apple's epoch starts on January 1st, 2001 for some reason... 16 | # http://apple.stackexchange.com/questions/114168 17 | messages = cur.execute(""" 18 | SELECT 19 | chat.chat_identifier, 20 | message.text, 21 | datetime (message.date / 1000000000 + strftime ("%s", "2001-01-01"), "unixepoch", "localtime") AS message_date, 22 | message.is_from_me 23 | FROM 24 | chat 25 | JOIN chat_message_join ON chat. "ROWID" = chat_message_join.chat_id 26 | JOIN message ON chat_message_join.message_id = message. "ROWID" 27 | """) 28 | db_entries = [] 29 | for message in messages: 30 | if message[1] is not None: 31 | title = "iMessage Thread with " + message[0] 32 | content = 'Me: ' + message[1] if message[3] else 'Other: ' + message[1] 33 | time = from_timestamp(to_timestamp(message[2])).isoformat() 34 | db_entries.append({'source': imessage_prefix, 'title': title, 'content': content, 'time': time, 'link': ''}) 35 | db.save_records(db_entries) 36 | # TODO: Get a contact's name from Contacts.app 37 | -------------------------------------------------------------------------------- /indexer/src/sources/source.py: -------------------------------------------------------------------------------- 1 | from indexer.src.lib.database2 import Database 2 | from indexer.src.sources.fb_messenger import get_messenger_records 3 | from indexer.src.sources.google_location import get_location_data 4 | from indexer.src.sources.imessage import get_imessage_records 5 | 6 | 7 | def get_records(db: Database): 8 | get_imessage_records(db) 9 | get_messenger_records(db) 10 | get_location_data(db) 11 | -------------------------------------------------------------------------------- /indexer/src/sources/word_docs.py: -------------------------------------------------------------------------------- 1 | word_docs_path = "./data/journal/word/*.docx" 2 | # messenger_path = "./data/fb-messenger/messages/messages/inbox/mackenziepatel_8sbrouv7sg/*.json" 3 | messenger_prefix = "docs" 4 | 5 | # def get_word_docs_records(): 6 | -------------------------------------------------------------------------------- /static/docs/TODO.md: -------------------------------------------------------------------------------- 1 | # Integrations (Search) 2 | - Readwise Quotes 3 | - Readwise Links 4 | - Twitter Liked 5 | - Roam 6 | - Journal Entries (Google Docs, Notion) 7 | 8 | # Contextual Data (Timeline) 9 | - Location History (Google) 10 | - Photos 11 | - Reading History (Kindle, Readwise) 12 | - Biometrics (HR, Sleep, Exercise) 13 | - Browser / Search History 14 | - YouTube Watch History 15 | - Spotify Listening History 16 | - Manual Data 17 | -------------------------------------------------------------------------------- /static/mvp-recording.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nt92/memex/d83781ab4a6a0b57637c0278696dafbf35edcc3b/static/mvp-recording.mov --------------------------------------------------------------------------------