├── .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 |
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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------