# AppWorks Personal Project
#### Brief: 復刻[Church Notes](https://www.churchnotesapp.com/),加上[Obsidian](https://help.obsidian.md/Plugins/Graph+view)的GraphView功能
## unit test
[TDD](https://medium.com/%E6%88%91%E6%83%B3%E8%A6%81%E8%AE%8A%E5%BC%B7/tdd-test-driven-development-%E6%B8%AC%E8%A9%A6%E9%A9%85%E5%8B%95%E9%96%8B%E7%99%BC-%E5%85%A5%E9%96%80%E7%AF%87-e3f6f15c6651)測試導向開發:先把測試&預期結果寫好,再去開發function
用[jest](https://jestjs.io/docs/getting-started)框架,[test檔名命名方式](https://create-react-app.dev/docs/running-tests)
* describe:測試描述
* test:測試內容,可以改為語意化的寫法(describe it ...)
```
describe('add function', () => {
it('should sum 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
});
```
* 加x的可以跳過
```
describe('add function', () => {
xit('should sum 1 + 2 to equal 3', () => { //這邊
expect(sum(1, 2)).toBe(3);
});
});
xdescribe('add function', () => { //這邊
it('should sum 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
});
```

* 門檻:需熟悉測試的語法跟方法 [mock/stub/spy/fake](https://medium.com/@henry-chou/%E5%96%AE%E5%85%83%E6%B8%AC%E8%A9%A6%E4%B9%8B-mock-stub-spy-fake-%E5%82%BB%E5%82%BB%E6%90%9E%E4%B8%8D%E6%B8%85%E6%A5%9A-ba3dc4e86d86)
* `npm test -- --coverage`可以知道測試覆蓋率
* 比較常做的UI測試:[Snapshot Testing](https://jestjs.io/docs/tutorial-react#snapshot-testing):把當下的畫面快照下來,去對照
* [測試使用者行為](https://jestjs.io/docs/tutorial-react)
## 互測
[互測重點](https://www.notion.so/d1cd99dccca943c98442bbf0e826ef90)
## Custom Hook
其實就是單純的拆function,並且function裡有用到react的原生hook,就是Custom Hook
```
//很像useRef
function useHashtags = () {
const [hashTags, setHashtags] = useState();
useEffect(()=>{...},[]);
...
return hashTags;
}
const Component = () => {
const hashTags = useHashTags();
...
}
```
```
//很像useState
function useHashtags = () {
const [hashTags, setHashtags] = useState();
...
return [hashTags, setHashtags];
}
const Component = () => {
const [hashTags, setHashtags] = useHashTags();
...
}
```
```
//可傳參數
function useHashtags = (arg) {
const [hashTags, setHashtags] = useState();
const [isLoading, setIsLoading] = useState();
...
return {hashTags, setHashtags, isLoading};
}
const Component = () => {
const {hashTags, setHashtags,isLoading} = useHashTags(arg);
...
}
```
* 官方建議hook名稱**用use開頭**,如果有用,react會幫你檢查有沒有符合hook規則
## [Presentational and Container Components](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)
共用元件管理分類
UI越偏向Presentational越好
* [classnames](https://www.npmjs.com/package/classnames): className管理套件
* [tailwindcss-classnames](https://www.npmjs.com/package/tailwindcss-classnames): tailwind className管理套件
## 專案管理
[Trello](https://trello.com/b/h5AdKvKx/yuchien-%E5%97%8E%E5%93%AA%E7%BD%90%E5%AD%90)
[plany way](https://planyway.com/)
## 開發紀錄
### redux + TypeScript 開發
研究官方文件和counter範例花不少時間,而且看不到進度會很緊張。reducerSlice裡同步的有弄懂,但非同步(Thunk)有點卡關。
**卡關**:後來找業界的朋友幫忙教學,發現有useContext + useRerducer加上對redux有初步研究,被教的時候很快就能理解asyncThunk(redux的code style蠻抽象的)
### Auto Verse
手刻功能搭配open api,利用chatGPT生成regex語法開發很順利,並且很幸運能兼容react-Quill
### Backlinks
使用react-Quill/quill-mention套件搭配redux,專案中最困難的功能,原理如下:
1. 先從redux拿到使用者所有的筆記並生成quill-mention能使用的data,再將quill-mention模組匯入react-Quill實現#轉換a連結功能
**卡關**:因為quill-mention模組是動態生成的,而react-Quill沒辦法接受變動的module參數(編輯器會跑不出來)而卡關,後來谷哥提議可使用useMemo來避免component re-render時重新生成quill-mention模組,再判斷如果redux資料尚未從firestore取得時,先避免渲染,待資料取得並生成模組後再渲染react-Quill編輯器。
**待解bug**:目前有使用者帳戶不能沒有筆記的bug,可能是模組不能沒有選項 || 我條件沒設好,暫時先用創建帳戶時就先塞一篇“**教學筆記**”給使用者解決
2. 生成link_notes資料給graphView使用
因Quill生成的資料為html格式,送出筆記時需透過regex先判斷有多少link,再把id存入link_notes array。
**待解bug**:link_notes資料結構現在是`{id:string,title:string}[]`,但其實用不到`title`,所以應該要改為`string[]`(為了抓title還跟chatGPT吵架,結果沒用到)
### GraphView
使用react-graph-vis套件搭配redux開發,分為full-graph跟note-graph(共用component)
1. full-graph:從redux先抓出所有的筆記title跟id生成nodes資料,再抓出所有筆記的link_notes生成edge資料,再根據node被連結的次數來計算node的大小(被連越多次越大顆)
2. note-graph:先從網址取得當前筆記的id,再過濾nodes資料,把跟此id沒任何關聯的筆記排除
react-graph-vis保留不少彈性,可以客製化style和針對不同事件的行為,而且edges資料與nodes資料不對應時也不會報錯,單純不顯示,非常好用!
### CSS [flex子元素宽度超出父元素]會造成推擠其他內容的問題(https://juejin.cn/post/6974356682574921765)
#### 解法
设置min-width:0可以解决当flex子元素的子元素大小为auto的情况;
设置overflow不为visible可以解决所有情况下的麻烦;
* grow的計算原理:
```
子元素宽度 = 子元素内容占据宽度(即content宽度) + flex: 1属性分配的宽度
```

### canva算graphView尺寸調整會拉伸的問題
可以用key={toggle}解: component偵測到key改變會重新un-mount,會重新做一次
## 好用的html tag
* progress 可以做進度條
* `<input required>` 可以指定必填
## Sue小波浪
[Creating an animated wave line with Tailwind CSS](https://daily-dev-tips.com/posts/creating-an-animated-wave-line-with-tailwind-css/)
[CSS Wavy Line Animation Effect For Website](https://www.youtube.com/watch?v=BaYd9Y_J71o)
## 功能spec:
### 輸入經節可自動插入經文:
1.[聖經api](https://bible.fhl.net/json/qb.php)
2.研究技術如何做到
### 分類整理筆記:
1.拖拉筆記位置
### 心智圖:
1.展示出有link的筆記
2.[Zoom-in Zoom-out](https://www.upbeatcode.com/react/how-to-implement-zoom-image-in-react/)
## 需研究套件:
[React-Graph-Vis](https://github.com/crubier/react-graph-vis/tree/master/example)
[React-Markdown](https://github.com/remarkjs/react-markdown)
[學長姐作品](https://bear-plus.yenchenkuo.com/home)
## API:
[信望愛站聖經 JSON API](https://bible.fhl.net/json/)
## [架構圖](https://www.figma.com/file/ztRMfXj4ADbe1Po9FWqVjQ/%E5%80%8B%E4%BA%BA%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B?node-id=0-1&t=03uwxZJzWMBM6fnf-0)
## Chrome exetention
[校友作品](https://chrome.google.com/webstore/detail/catalyst/jhpdbbakbcmoaondmjjjbojlakgkeilo)
[校友作品](https://chrome.google.com/webstore/detail/tabshaker/eigbinbgomoampdljpjabchdempfileg?hl=zh-TW&authuser=0)
## 技術選型
#### firebase cloud function
1. next.js
2. firebase cloud function(可以很快地寫出api/不用管理server)
3. 幫你把cors的api導回自己的firebase的server
#### firebase cloud storage
1. 注意容量
2. demo帳號/限制檔案大小
#### build -> extention
1. fuzzy search ->cloud function會去監聽firestore的資料,放到 [algolia](https://www.algolia.com/) search(第三方)裡,帳號要先升級
#### authentication 第三方登入
* 可以在前端寫第一步的阻擋`if(!userid){ return }`
* 或是在firestore的rule裡寫阻擋規則
#### firestore
* 如果有兩個where,會是複合式索引,如果撈不到資料,錯誤訊息關鍵字有索引,有可能是這個原因
* 可以利用索引找分類(key的概念)
* 設計資料結構:
看wirefram,看畫面需要哪些資料,再建立資料庫,我們會知道怎樣的格式最容易做渲染
* 是否容易query?
* firebase的search只會query那一筆的流量,前端search就是所有的
* [搜尋的運算子](https://firebase.google.com/docs/firestore/query-data/queries#query_operators)
* 關鍵字where in可以去bars裡抓某個array裡所有的id
* array contains
* [onAuthStateChanged](https://firebase.google.com/docs/auth/web/manage-users): 監聽登入狀態保持登入功能
#### firebase config
```
const firebaseConfig = {
apiKey: "AIzaSyAizA6LfYHyWib7-7cZb0LVZmpu0pa2qAo",
authDomain: "manna-jar.firebaseapp.com",
projectId: "manna-jar",
storageBucket: "manna-jar.appspot.com",
messagingSenderId: "806344207818",
appId: "1:806344207818:web:e892e0e500e7748ed2f333",
measurementId: "G-NB0SG7Q4HY"
};
```
## POC紀錄
### redux: reducer/dispatch
[RTK](https://redux-toolkit.js.org/rtk-query/overview)的API [Thunk](https://pjchender.dev/react/redux-thunk/):處理async事件 | React Redux:provider/Hook|[複數reducer處理方法](https://ithelp.ithome.com.tw/articles/10275697) | [官網基礎](https://redux.js.org/tutorials/essentials/part-1-overview-concepts) | [Redux & RTK](https://www.freecodecamp.org/news/redux-and-redux-toolkit-for-beginners/)
### [Firebase Authentication](https://javascript.plainenglish.io/firebase-authentication-with-firestore-database-78e6e4f348c6) with Firestore
```
export const signupUser = (userDetails) => {
//deconstruct the users details we will need these later
const {firstName, lastName, birthday, email, password} = userDetails
return () => {
//user firebase using the appropriate firebase method
firebase.auth().createUserWithEmailAndPassword(email, password)
.then(() => {
//Once the user creation has happened successfully, we can add the currentUser into firestore
//with the appropriate details.
firebase.firestore().collection('users').doc(firebase.auth().currentUser.uid)
.set({
firstName: firstName,
lastName: lastName,
birthday: birthday,
email: email
})
//ensure we catch any errors at this stage to advise us if something does go wrong
.catch(error => {
console.log('Something went wrong with added user to firestore: ', error);
})
})
//we need to catch the whole sign up process if it fails too.
.catch(error => {
console.log('Something went wrong with sign up: ', error);
}
},
};
```
```
import {GoogleSignin} from '@react-native-community/google-signin';
import auth, {firebase} from '@react-native-firebase/auth';
import firestore from '@react-native-firebase/firestore';
GoogleSignin.signIn()
.then(data => {
// data provides us with an idToken and accessToke, we use these to set up a credential
const googleCredential = auth.GoogleAuthProvider.credential(data.idToken,data.accessToken);
try {
firebase.auth().signInWithCredential(googleCredential)
.then(user => {
//after we have the credential - lets check if the user exists in firestore
var docRef = firestore().collection('users').doc(auth().currentUser.uid);
docRef.get()
.then(doc => {
if (doc.exists) {
//user exists then just update the login time
return user
} else {
//user doesn't exist - create a new user in firestore
resolve(addNewUserToFirestore(user));
}
})
.catch(error => {
console.error('Checking if customer exists failed" ' + error);
});
})
.catch(error => {
console.error('GoogleSignIn to firebase Failed ' + error);
})
} catch (error) {
console.log("Something generic went wrong, ", error )
}
})
.catch(error => {
console.error('GoogleSignIn to firebase Failed ' + error);
})
function addNewUserToFirestore(user) {
const collection = firestore().collection('users');
const {profile} = user.additionalUserInfo;
const details = {
firstName: profile.given_name,
lastName: profile.family_name,
fullName: profile.name,
email: profile.email,
picture: profile.picture,
createdDtm: firestore.FieldValue.serverTimestamp(),
lastLoginTime: firestore.FieldValue.serverTimestamp(),
};
collection.doc(auth().currentUser.uid).set(details);
return {user, details};
}
```
### Auto Verse
[Auto Verse poc](https://codesandbox.io/s/autoverse-umzlk4)
```
import "./styles.css";
import React, { useState } from "react";
const BibleReference = async (book, chapter, verse) => {
try {
const response = await fetch(
`https://bible.fhl.net/json/qb.php?chineses=${book}&chap=${chapter}&sec=${verse}`
);
const data = await response.json();
let verses = `${book} ${chapter}:${verse}\n`;
data.record.map((sec) => {
verses += `[${sec.sec}]${sec.bible_text}`;
});
return verses;
} catch (error) {
console.error(error);
}
};
function App() {
const [noteContent, setNoteContent] = useState("");
const handleKeyDown = async (event) => {
// Check if the pressed key is the space bar
if (event.key === " ") {
// Get the current cursor position in the text area
const cursorPosition = event.target.selectionStart;
// Split the text content into two parts:
// 1. text before the cursor position
// 2. text after the cursor position
const beforeCursor = noteContent.slice(0, cursorPosition);
const afterCursor = noteContent.slice(cursorPosition);
// Find the last Bible reference before the cursor position
const lastReference = beforeCursor.match(
/([\u4E00-\u9FFF]{1,2})(\d+):([\d,-]+)/
);
if (lastReference) {
// If there is a Bible reference, insert the BibleReference component
const referenceStart = beforeCursor.lastIndexOf(lastReference[0]);
const referenceEnd = referenceStart + lastReference[0].length;
const book = lastReference[1];
const chapter = lastReference[2];
const verse = lastReference[3];
await BibleReference(book, chapter, verse) // Call BibleReference component with reference prop
.then((text) => {
setNoteContent(
beforeCursor.slice(0, referenceStart) +
text +
afterCursor.slice(referenceEnd)
);
});
// Prevent the space key from being inserted into the text area
event.preventDefault();
}
}
};
return (
<div>
<textarea
value={noteContent}
onChange={(event) => setNoteContent(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
);
}
export default App;
```
### backlink實作
#### Quill-mention
[quill-mention](https://github.com/quill-mention/quill-mention)
[參考轉成link的方案](https://stackoverflow.com/questions/69704201/how-to-insert-link-for-hashtags-and-mentions-in-react-quill)
[poc轉成`<a href="">`](https://codesandbox.io/s/quill-mentions-bian-cheng-lian-jie-lftx1i)
[markdown parser poc](https://codesandbox.io/s/zi-ding-yi-react-markdown-parser-s1u4g5)

* [Tailwind處理Quill h1大小方法](https://tools.wingzero.tw/article/sn/973)
#### 自定義markdown parser
客製化可以解讀`#title(url?id=note.id)`的parser
```
md = md.replace(/#(\w+)\(([^)]+)\)/g, (match, title, url) => {
return `<a value=`${url}` class="text-red" href=${url}>${title}</a>`;
});
```
* 一定要透過dangerouslySetInnerHTML,所以不能吃component, style也要用tailwind
* 可以透過bubbling事件來達到`<Link>`的功能
```
<div
dangerouslySetInnerHTML={{ __html: note }}
style={{ border: "1px solid #006888", padding: "0 10px" }}
onClick={//TODO:bubbling的事件,用nevigate to來實現link的功能}
></div>
```
#### 編輯器
1.先把notes全部抓下來,title/id存成state
```
useEffect(() => {
const unsubscribe = db.collection("notes").onSnapshot(snapshot => {
const notesData = snapshot.docs.map(doc => {
const { title } = doc.data();
return
//TODO要製作graph{nodes:[...],edges:[...]}還有抓出跟當前筆記有關的Links
{
id: doc.id,
title
};
});
setNotes(notesData);
});
return () => unsubscribe();
}, []);
```
2.onKeyPress事件(e.target.value === #)跳出輸入框,根據輸入搜尋state裡的id,下方會顯示dropdown供點擊
3.點擊選項(id)後會自動置入#note.title(url?id=note.id)
4.點擊submit時會抓取符合#note.title(url?id=note.id)規則的資料,加入link-notes[]裡存到firestore
```
const regex = /#(\w+)\(([^)]+)\)/g;
const text = 'This is a #Note1(/note?id=1) and #Note2(/note?id=2).';
const matches = text.matchAll(regex);
for (const match of matches) {
console.log(match);
}
```
在使用matchAll()方法後,matches會是一個Iterator物件,可以使用for...of迴圈或是將其轉為陣列後使用。
```
Array [ "#Note1(/note?id=1)", "Note1", "/note?id=1", index: 10, input: "This is a #Note1(/note?id=1) and #Note2(/note?id=2)." ]
Array [ "#Note2(/note?id=2)", "Note2", "/note?id=2", index: 26, input: "This is a #Note1(/note?id=1) and #Note2(/note?id=2)." ]
```
### [AI助理](https://platform.openai.com/docs/guides/chat/chat-vs-completions)
`openai.createChatCompletion()` 方法接受以下参数:
1. **model**:指定要使用的模型。目前支持的模型是 "gpt-3.5-turbo",它是 OpenAI GPT-3.5 Turbo 模型,具有强大的生成能力。
2. **messages**:包含一个数组,其中包含对话中的用户和助手角色的消息。每个消息都有两个属性:**role** 表示角色 和 **content** 表示消息内容。
**role**包括:
-**system**:系统角色,用于传递系统级指令或提示,以控制生成模型的行为。
-**user**:用户角色,表示用户的消息或输入。
-**assistant**:助手角色,表示生成模型的回复或生成的文本。
其他系统级参数:您可以在 messages 中使用 system 角色来向模型传递系统级指令,例如更改温度或最大令牌限制,以影响生成的行为。
**Response format:**
```
{
'id': 'chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve',
'object': 'chat.completion',
'created': 1677649420,
'model': 'gpt-3.5-turbo',
'usage': {'prompt_tokens': 56, 'completion_tokens': 31, 'total_tokens': 87},
'choices': [
{
'message': {
'role': 'assistant',
'content': 'The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers.'},
'finish_reason': 'stop',
'index': 0
}
]
}
```
### react-graph-vis
[typeScript定義檔](https://gist.github.com/ChadJPetersen/2e2587bbd753c6a384c02519183e2031)
[graphview的codesandbox]()
### D3
了解如何使用D3.js和React来实现一个简单的图表:
```
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
function GraphView({ data }) {
const svgRef = useRef();
useEffect(() => {
const svg = d3.select(svgRef.current);
const nodes = data.nodes;
const links = data.links;
// 创建力导向图力学模拟器
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id))
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(400, 400));
// 绘制节点
const node = svg.selectAll('.node')
.data(nodes)
.enter()
.append('circle')
.attr('r', 10)
.attr('fill', '#2196f3')
.call(d3.drag()
.on('start', dragstart)
.on('drag', drag)
.on('end', dragend));
// 绘制连线
const link = svg.selectAll('.link')
.data(links)
.enter()
.append('line')
.attr('stroke', '#bdbdbd')
.attr('stroke-width', 2);
// 更新节点和连线位置
function ticked() {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node
.attr('cx', d => d.x)
.attr('cy', d => d.y);
}
// 开始拖拽节点
function dragstart(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
// 拖拽节点过程中更新节点位置
function drag(event, d) {
d.fx = event.x;
d.fy = event.y;
}
// 结束拖拽节点
function dragend(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
// 每次模拟器更新时调用ticked函数更新节点和连线位置
simulation.on('tick', ticked);
// 在组件卸载时停止模拟器
return () => {
simulation.stop();
};
}, [data]);
return (
<svg ref={svgRef} width="800" height="800"></svg>
);
}
export default GraphView;
```
Obsidian的Graph View使用了D3.js和webcola这两个开源库来实现。其中,D3.js用于创建可交互的力导向图,而webcola用于布局节点和连线。同时,Obsidian还使用了一些自定义的代码来增强Graph View的功能,例如支持节点标签、过滤器和笔记搜索等。
Logseq的Graph View同样使用了D3.js来创建力导向图,并使用了React和Redux来构建用户界面和管理应用程序的状态。Logseq的Graph View支持对笔记进行标记、搜索和筛选,并且可以根据笔记的重要性和相似性来调整节点的大小和颜色。此外,Logseq还提供了一个强大的插件API,使用户可以自由地扩展和定制Graph View的功能。
### Link的hover事件POC
1. `dangerouslySetInnerHTML={{ __html: currentNote.content }}`沒辦法做到分辨node,因為置入的html不會生成node
2. console.dir可以印出HTMLnode的對應屬性
3. React的[hover](https://upmostly.com/tutorials/react-onhover-event-handling-with-examples)事件為
* onMouseEnter={() => {}}
* onMouseLeave={() => {}}
* onMouseOver={() => {}}
```
onMouseEnter={(e) => {
const nodeList = e.target.childNodes;
nodeList.forEach((node) =>
console.log(node.childNodes[0].dataset?.id)
);
}}
```
目前可做到取得links的id值,但無法解決位置問題
### 分頁功能
```
// state
const [currentPage, setCurrentPage] = useState<number>(1);
// 變數
const PAGE_SIZE = 10;
const totalPages = Math.ceil(articles.length / PAGE_SIZE);
// currentPageArticle 這個變數放當前頁面要顯示的文章(displayedArticles 是可以使用搜尋功能的文章,預設會顯示所有文章)
const currentPageArticles = displayedArticles
// 依時間排序
.sort((a, b) => {
if (a.date && b.date) {
return new Date(b.date).getTime() - new Date(a.date).getTime();
} else {
return 0;
}
})
// 用 slice 切出點到的分頁要顯示的文章
.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handlePageChange = (pageNumber: number) => {
setCurrentPage(pageNumber);
};
// return 出 pagination 的元件
<Pagination>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => handlePageChange(page)}
disabled={page === currentPage}
>
{page}
</button>
))}
</Pagination>
```