把敏感設定的內容跟 Angular 程式分離

以我用過 Django / Docker 的經驗,一般都是利用環境變數來作分離,程式在執行時,會去讀取環境變數來當作設定,盡量不依靠設定檔,進而達到 12 factor –  config 的要求。

到了 Angular ,就需要做出調整。Angular 是需要先進行建置產出 javascript / html / css ,再把這些產出的檔案放到網頁伺服器,瀏覽器再去下載,所以完全沒辦法使用環境變數作為設定,必須要把設定寫到檔案裡,再做建置。那設定寫到檔案的話,其實我們都知道,這些敏感設定內容不適合放到 git repository,即使是 private repository,這樣會有安全上的風險。那這樣就兩難了啊?

說來很妙,我是覺得官方早就應該有比較好的作法了,但是,並沒有。不管怎麼樣,我還是找到解決方法了,基本上跟我想的也差不多。

這兩篇文章的作法大同小異,這邊作簡單的說明:

  1. 移除 src/environment/environment.ts
  2. 新增一個腳本,這個腳本會讀取環境變數,並產生一個新的 src/environment/environment.ts
  3. 修改 package.json
    1. 在 scripts 裡增加 config,這個 config 會去執行步驟 2 的腳本,用以產生 environment.ts
    2. 修改 scripts 裡的 start / build,在執行原來的指令前,先執行 npm run config 去產生 environment.ts

下面兩個片段是關鍵的改動部分:

// set-env.ts
// 放在專案根目錄下
import { writeFile } from 'fs';
import { name, version } from './package.json';


// Configure Angular `environment.ts` file path
const targetPath = './src/environments/environment.ts';


// 載入 colors 模組,主要是用來顯示
const colors = require('colors');


// 可以把敏感內容放到 .env 檔案裡,nodejs 會把內容讀出來,放到環境變數裡
// 也可以在執行 npm run 之前,先設定好環境變數
const result = require('dotenv').config();
if (result.error) {
  // throw result.error;
}


// 原來 environment.ts 裡的內容,把該替換的,用 template literals 來替換

// https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Template_literals
const envConfigFile = `export const environment = {
  production: ${process.env.PRODUCTION},
  VERSION: '${version}’,
};

`;


// 顯示
console.log(colors.magenta('The file `environment.ts` will be written with the following content: \n'));
console.log(colors.grey(envConfigFile)); writeFile(targetPath, envConfigFile, (err) => {
  if (err) {
    throw console.error(err);
  } else {
    console.log(colors.magenta(`Angular environment.ts file generated correctly at ${targetPath} \n`));
  }
});
// package.json
// 只摘錄重要的部分
{
  “scripts”: {
    "config": "ts-node -O '{\"module\": \"commonjs\"}' ./set-env.ts",
    "start": "npm run config && ng serve",
    "build": "npm run config && ng build”
  }
}

Angular i18n

Angular 官方提供的套件以及教學:Angular Internalization (i18n)

文章裡一大堆,簡單整理如下:

  1. 安裝:ng add @angular/localize
    • 這個步驟會幫你在 polyfills.ts 裡加入必要的 import
  2. 使用,這部份分為 HTML 跟程式
    • HTML,在需要多國語言的標籤加入 i18n 的屬性
      • <span i18n>Hello world</span>
      • <span i18n=“@@span_hello”>Hello world</span> ,用 i18n=“@@span_hello” 的好處是,產出的翻譯檔裡不會是一個隨意的數字,而是一個對開發者來說比較明確的名稱。
      • <input i18n-placeholder> 這個是有些屬性本身需要多國語言的,就在前面加上 “i18n-“
    • 程式,字串前方加上 $localize ,並且改用 back quote,例如:$localize`hello world`
  3. 萃取,用 ng xi18n –output-path src/i18n 就可以把 HTML 裡有標 i18n 的字串萃取到 src/i18n/messages.xlf 裡
  4. 翻譯,先把上個步驟取得的 messages.xlf 複製為 messages.zh_Hant.xlf ,再去編輯。這邊提供一個簡易的網頁工具 – tiny-translator,在處理上會方便很多。開啟以後,要先建立專案,然後上傳 .xlf 檔案,接著就可以進行翻譯了。翻譯好,再下載下來即可。
  5. 合併,程式開發中難免會有增刪,每次用 xi18n 基本上都是重新萃取一次,等於是又要再搞一次合併的功夫,這太累。tiny-translator 的作者有提供另外一個工具 – xliffmerge 
    • 安裝:npm install -g ngx-i18nsupport
    • 在 package.json 的 scripts 裡加入 “extract-i18n”: “ng xi18n –output-path src/i18n && xliffmerge –profile xliffmerge.json en de” ,裡面的 en, de 等 locale 請依照自己的需求作調整
    • 新增 xliffmerge.json ,這個檔案請參考後面。
    • 使用 npm run extract-i18n 就可以自動萃取字串並且作合併了。
  6. 專案的建置,主要是修改 angular.json,有三個部分:
    • projects / your_project_name 加入 “i18n”: {“sourceLocale”: “en”, “locales”: {“de”: “src/i18n/messages.de.xlf”}}
    • projects / your_project_name / architect / build / configurations 裡加入 “de”: {“localize”: [“de”]}
    • projects / your_project_name / architect / serve / configurations 裡加入 “de”: {“browserTarget”: “ng-hosting:build:de”}
  7. 要執行 ng build / ng serve 時,就可以用
    • ng build –configuration=production,de
    • ng serve –configuration=de
  8. 補充一個我覺得很重要的部分,就是一個語言要建置一次,所以一般的佈署會是這樣的,建置好 zh ,放在 zh/ 目錄下,建置好 de,放在 de/ 目錄下,然後在 nginx/apache 的設定裡去依照 header 的 language 去導向到對應的目錄去。這篇 Deploying an i18n Angular app with angular-cli 的後面有教怎麼去設定 apache / nginx。

看到這邊,你可能會想,那程式裡標上 $localize 的字串呢?嗯,ng xi18n 並不會把這些字串萃取出來 (issue),所以這部份得自己手動處理 😣 

P.S. 用 ngx-i18nsupport 的 tooling 可以把上面講的簡化掉,像是加入 npm package、在 package.json 加入 extract-i18n 、在 angular.json 加入設定等等,一次就搞定了,我是已經用了才看到這個 tooling ,有點相見恨晚。

// xliffmerge.json
{
  "xliffmergeOptions": {
    "srcDir": "src/i18n",
    "genDir": "src/i18n",
    "i18nFile": "messages.xlf",
    "i18nBaseFile": "messages",
    "i18nFormat": "xlf",
    "encoding": "UTF-8",
    "defaultLanguage": "en",
    "languages": ["en", "de"],
    "removeUnusedIds": true,
    "supportNgxTranslate": false,
    "ngxTranslateExtractionPattern": "@@|ngx-translate",
    "useSourceAsTarget": true,
    "targetPraefix": "",
    "targetSuffix": "",
    "beautifyOutput": false,
    "preserveOrder": true,
    "allowIdChange": false,
    "autotranslate": false,
    "apikey": "",
    "apikeyfile": "",
    "verbose": false,
    "quiet": false
  }
}

螢幕錄製與字幕合成

螢幕錄製的軟體很多,像是 FonePaw、Soundflower、ApowerREC、Nonosnap、Kap、Camtasia Studio、iShowU HD 等等的,我是使用 Quicktime player 。

執行 Quicktime palyer 以後,會要你選存放位置,我是不管他,按「取消」。用滑鼠點按「檔案」 > 「新增螢幕錄製」,接著畫面會出現一個工具列視窗,請先選定錄製全螢幕或是錄製指定區域,然後再按下選項:

  • 勾選使用 MacBookPro 的麥克風
  • 勾選滑鼠點按

這邊可以加選計時器,這可以讓錄製的動作在指定秒數後開始,按下錄製就可以開始錄製影片了。錄製的時候,要注意找個比較安靜的地方,說話的聲音也要提高音量,這樣錄製的效果會比較好。

錄製完成以後,可以使用 iMovie 編輯,在 iMovie 裡可以加入過場動畫,調整音量,也可以逐句上字幕。調整音量是點按上方選單的「修改」> 「增強」

因為我懶得逐句上字幕,所以使用別的方式。首先先把製作好的影片匯出,先回到主畫面的計劃案,點選你計劃案右下角的 「…」,選「分享計劃案」>「檔案」,這樣就可以把檔案匯出到指定資料夾去了。

再來是抽取出音訊,先安裝 ffmpeg :brew install ffmpeg

在終端機輸入 ffmpeg -i xxx.mp4 xxx.mp3

要調整聲音的話,也可以在這邊作(參考自ffmpeg的wiki):ffmpeg -i input.wav -filter:a “volume=10dB” output.wav

取出以後,再來是試著從音訊轉出字幕,我試了兩個方法,第一個方法是用布丁大大web speech to text ,這有蠻多教學文章的:

在這邊就可以上傳音訊檔案,然後播放時,就會自動轉出字幕,這邊由於是利用到播放跟麥克風,所以在轉換時,一樣需要一個比較安靜的地方,同時音訊檔的音量也需要大一點,轉出來效果會比較好。

第二個方法是自己手打,因為環境有點吵雜的關係,我後來是用這方法。這邊是用了 oTranscribe 這個網站,一樣是要上傳音訊,oTranscribe 提供了便利的介面,讓你可以邊聽邊打。完成以後,可以匯出文字檔。這邊大致講一下怎麼使用:

  • ESC: Play/Pauser
  • F1: 倒轉
  • F2: 快轉
  • Ctrl +j (在Mac是用Command+j) : 插入時間

如果你需要教學的話,這裡有:oTranscribe 線上逐字稿工具,支援讀取電腦影片製作字幕

取得文字檔以後,還需要轉換為通用的 srt 檔案,這一樣有網站提供這功能: Subtitle tools – Convert Subtitles to Srt

將剛剛取得的文字檔上傳上去,按下「Convert to Srt」,然後就可以下載了。

最後是將字幕合併到影片裡。

  1. 安裝 handbrake: brew cask install handbrake
  2. 從啟動台找到 HandBrake,執行
  3. 選擇前面匯出的 mp4,再選擇 “Subtitles” 頁籤,點按「Tracks」> 「Add External Subtitles track」加入前面取得的 .srt 檔案
  4. 調整字幕的語言,再勾選 Burned In 
  5. 在下方填入輸出的檔名,並選擇要輸出的資料夾。
  6. 按下上方的「Start」

需要教學的話,一樣有:SRT 字幕筆記

這樣就完成啦。

其他的參考資料:

Angular 嵌入 redoc API 文件

原本是打算把 redoc-cli 產生出來的文件直接嵌進去的,可是這樣子在文件有變動時,就會又要再做一次產生、嵌入,這樣不太好。最好還是可以自動依據寫好的 OpenAPI specification 來自動產出,這才是比較好的作法。

第一個待解決的問題是怎麼把 OpenAPI specification JSON 放到 Angular 專案裡,並且可以讀出來使用。關於這個,我是找到 How To Read Local JSON Files In Angular 這篇文章,方法挺簡單的,就把 json 檔案丟到 src/assets 下,然後直接在程式裡用 import 。

// src/app/xxx/xxx.component.ts
import SampleJson from '../../assets/SampleJson.json’;

// 後續程式碼就直接引用 SampleJSON 即可。

但修改完,TypeScript compiler 會有錯誤訊息,這得要改 tsconfig.json 在 compilerOptions 裡加入 resolveJsonModule 跟 esModuleInterop

{ "compilerOptions": { "resolveJsonModule": true, "esModuleInterop": true } }

第二個問題是,angular template 裡不能直接寫 script tag,這個可以實作 AfterViewInit ,然後用 DOMElement 來動態插入 (參考來源:https://stackoverflow.com/questions/38088996/adding-script-tags-in-angular-component-template/43559644)。細節的說明,我就直接寫在下面程式的註解裡。

import {
  Component,
  OnInit,
  ViewEncapsulation,
  ElementRef,
  AfterViewInit } from '@angular/core’;
// (1) 剛剛前面提到的,引用 OpenAPI specification JSON
import APIJson from '../../assets/api.json';

@Component({
  selector: 'app-documentation',
  templateUrl: './documentation.component.html',
  styleUrls: ['./documentation.component.scss'],
  encapsulation: ViewEncapsulation.None
})
export class DocumentationComponent implements OnInit, AfterViewInit {  // (2) Component 要實作 AfterViewInit 這介面

  constructor(private elementRef: ElementRef) { }  // (3) 引入 ElementRef ,Angular 會幫忙作動態注入

  ngAfterViewInit(): void {
    // (4) 這裡就是在頁面載入後要作的事情
    this.insertSpec();
    this.insertScript();
  }

  insertScript(): void {
    // (5) 插入 script element,這裡要載入 redoc 的腳本
    const redocScript = document.createElement('script');
    redocScript.type = 'text/javascript';
    redocScript.src = 'https://unpkg.com/redoc@next/bundles/redoc.standalone.js’;  // (6) 這腳本的網址是閱讀 redoc-cli 的原始碼以後取得的。
    this.elementRef.nativeElement.appendChild(redocScript);

    // (7) 插入另外一個 script element,這裡主要是執行 redoc,讓 redoc 能解析 json 並顯示出文件。
    const initScript = document.createElement('script');
    initScript.type = 'text/javascript';
    initScript.textContent = `
    const __redoc_state=${JSON.stringify(APIJson)};
    var container = document.getElementById('redoc');
    Redoc.hydrate(__redoc_state, container);
    `;  // (8) 字串要允許多行,可以使用 backquote ` https://stackoverflow.com/questions/35225399/multiline-string
    this.elementRef.nativeElement.appendChild(initScript);
  }

  insertSpec(): void {
    // (9) 插入 element ,讓腳本知道要根據哪個 element 來作處理。
    let s = document.createElement('redoc');
    s.setAttribute('spec-url', 'assets/api.yml');
    this.elementRef.nativeElement.appendChild(s);
  }

  ngOnInit(): void {
  }
}

至此就大功告成啦。

P.S. redoc 在 1.x 時,是有支援 Angular 的,但到了 2.x ,就改用 react 了。

不是只有 console.log

這篇 JavaScript console is more than console.log() 講了幾種 console 的用法,console 不是只有 log 可以用。

  1. console.warn() / console.error() 可以依據訊息等級顯示出不同的顏色。
  2. console.group() / console.groupEnd() 可以讓這中間的 log 輸出都摺疊起來,避免太多。
  3. console.table() 是印出表格,表格的形式比較容易查看資料內容。
  4. console.trace() 是印出 stacktrace 資訊
  5. console.time() / console.timeEnd() 可以輸出這中間的指令花了多少時間。
  6. console.clear() 清理終端機輸出內容
  7. console.dir() 可以印出物件的屬性。

另外,現在瀏覽器的 js 都支援 backquote – template literals ,也就是 ` ` ,所以也可以這樣用 console.log(`obj=${JSON.stringify(x)}`);

Heroku 的 deploy

以前只知道可以用 git push 來 deploy,今天用 google 找了一下,發現 heroku 已經提供了 container registry,所以現在可以用 docker 來佈署。

所以要先寫好 Dockerfile,到時候會方便很多。

MacOS 換動態桌布

MacOS 可以換動態桌布,會隨著時間而變化,這真的很酷。

透過免費資源網路社群的這篇:Dynamic Wallpaper Club 提供 macOS 動態桌面下載,依時間顯示不同效果 知道了 Dynamic Wallpaper Club 這個收錄許多動態桌布的網站,就上去找,真的有很多。

在換的時候有個小插曲,因為我接了外接螢幕,所以就在想怎麼換另外一個螢幕的桌布,可是一直找都沒找到。後來才發現,當進入「系統偏好設定」>「桌面與螢幕保護程式」時,兩個螢幕同時都會出現「桌面與螢幕保護程式」視窗,這時候就可以去個別指定了。

筆記:Firebase functions with authentication

主要可以 trigger 兩個,一個是 onCreate,一個是 onDelete

onCreate 在以下情況會被觸發

  • A user creates an email account and password.
  • A user signs in for the first time using a federated identity provider.
  • The developer creates an account using the Firebase Admin SDK.
  • A user signs in to a new anonymous auth session for the first time.
exports.sendWelcomeEmail = functions.auth.user().onCreate((user) => {
  // ...
});


onDelete 在使用者被刪除時觸發

exports.sendByeEmail = functions.auth.user().onDelete((user) => {
  // ...
});

裡面的 user 主要就是 UserRecord

email / displayName / customClaims / uid / …. 等欄位都有。

onDelete 可以用來做刪除 firestore/realtime database 上的資料,需要考慮 functions 能執行多久。

修改 git commit 的 author name

因為 commit 時的作者身份錯亂,所以要改掉。在 StackOverflow 查到這篇:How to change the commit author for one specific commit?

  1. 先 git rebase -i ,然後把 pick 全改成 edit
  2. 接著,git 會切到第一個 commit 結束的時間點
  3. 輸入 git commit –amend —author=”Author Name <email@address.com>” –no-edit ,這樣就會把這次 commit 的 author 修改為 Author Name <email@address.com> 。對,別忘了,這邊要改成你自己的名字跟 e-mail 。
  4. 再輸入 git rebase –continue 切到下個 commit
  5. 就這樣依序作業直到結束。

很麻煩,如果有上千個 commit 的話….(眼神死),所以以後還是要把 author 設定好,否則又要再苦一次。

axios catch

一般是寫 .catch((err) => {console.log(err);}

這樣只會看到 http status 的錯誤,不能取得 response。那該怎麼取得 error response 呢?axios 文件有寫了,用 err.response: https://github.com/axios/axios#handling-errors

axios.get('/user/12345')
  .catch(function (error) {
    if (error.response) {
      // The request was made and the server responded with a status code
      // that falls out of the range of 2xx
      console.log(error.response.data);    // 取得內容,用 error.response.data
      console.log(error.response.status);  // 取得狀態碼,用 error.response.status
      console.log(error.response.headers); // 取得表頭,用 error.response.headers
    } else if (error.request) {
      // The request was made but no response was received
      // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
      // http.ClientRequest in node.js
      console.log(error.request);
    } else {
      // Something happened in setting up the request that triggered an Error
      console.log('Error', error.message);
    }
    console.log(error.config);
  });